GitButler Workspace Commit

This is placeholder commit and will be replaced by a merge of your virtual branches.

Due to GitButler managing multiple virtual branches, you cannot switch back and
forth between git branches and virtual branches easily. 

If you switch to another branch, GitButler will need to be reinitialized.
If you commit on this branch, GitButler will throw it away.

For more information about what we're doing here, check out our docs:
https://docs.gitbutler.com/features/branch-management/integration-branch
This commit is contained in:
GitButler authored and Erik Dunin committed 2026-10-02 07:47:57 +02:00
commit bc30f2900b
49 files changed
+2449 -5738

No files matched your search

BIN
View File
Binary file not shown.
+14 -3
View File
@@ -5,7 +5,7 @@
und verwaltet wöchentliche Sitzungen (Sitzungswahl, -anlage, -duplikat und -löschung im
seitlichen Menü) je Abteilung (ELT, HLS, Architektur). Sie lädt das gemeinsame Stylesheet
styles.css sowie der Reihe nach die Module js/globals.js, js/utils.js, js/ui.js,
js/date-picker.js, js/csv.js, js/server.js, js/store.js, js/weeks.js, danach die
js/date-picker.js, js/server.js, js/store.js, js/weeks.js, danach die
tab-spezifischen Module js/board/board.js, js/people/people.js,
js/projects/projects.js, js/load/load.js sowie js/gantt.js, js/showmode.js,
js/tabs-auth.js, js/pdf.js und js/init.js; crypto-js und pdfmake kommen von einem
@@ -77,6 +77,15 @@
<button class="secondary" onclick="exportPDF()">PDF Export</button>
<button class="secondary" onclick="startShowMode()">Show Mode</button>
</div>
<hr class="menu-divider">
<div class="menu-section">
<div class="menu-label">Backup</div>
<button class="secondary" onclick="exportCSV()">CSV Export</button>
<button class="secondary" onclick="downloadBackup()">DB Backup</button>
<button class="secondary" onclick="importDb()">DB Import</button>
</div>
</div>
</div>
@@ -108,7 +117,7 @@
<div class="table-wrap">
<table id="boardTable"></table>
</div>
<div id="boardLegend" style="margin-top:16px; padding:12px; background:#f7fafc; border-radius:6px; font-size:12px;"></div>
<div id="boardLegend"></div>
</div>
<!-- LOAD -->
@@ -227,7 +236,6 @@
<script src="/js/utils.js?v=5"></script>
<script src="/js/ui.js?v=5"></script>
<script src="/js/date-picker.js?v=5"></script>
<script src="/js/csv.js?v=5"></script>
<script src="/js/server.js?v=5"></script>
<script src="/js/store.js?v=5"></script>
<script src="/js/weeks.js?v=5"></script>
@@ -239,6 +247,9 @@
<script src="/js/showmode.js?v=5"></script>
<script src="/js/tabs-auth.js?v=5"></script>
<script src="/js/pdf.js?v=5"></script>
<script src="/js/csv.js?v=1"></script>
<script src="/js/backup.js?v=1"></script>
<script src="/js/import.js?v=1"></script>
<script src="/js/init.js?v=5"></script>
</body>
</html>
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
// -----------------------------
// Projektübersicht — Datenbank-Backup (Download)
// -----------------------------
// Stellt downloadBackup() bereit: lädt einen konsistenten Snapshot der
// SQLite-Datenbank über GET /backup als .db-Datei herunter (VACUUM INTO,
// WAL-sicher). Hängt im Seitenmenü von Test.html am Button "DB Backup" und
// nutzt baseUrl() aus js/server.js. Ergänzt das automatische Backup, das der
// Server beim Start in sessions/backups/ ablegt.
// downloadBackup: fordert den DB-Snapshot an und startet den Download.
// Kein Parameter, keine Rückgabe; Fehler werden über ein Alert gemeldet.
async function downloadBackup(){
try {
const res = await fetch(baseUrl() + '/backup');
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
const blob = await res.blob();
const filename = 'projektuebersicht_backup_'
+ new Date().toISOString().slice(0, 10) + '.db';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('DB-Backup fehlgeschlagen:', err);
alert('DB-Backup fehlgeschlagen: ' + (err.message || err));
}
}
+3 -3
View File
@@ -53,7 +53,7 @@ function formatStatus(command, value){
}
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
// csv.js für die Projektplan-Seite, die board.js nicht lädt).
// die Projektplan-Seite, die board.js nicht lädt).
// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
@@ -85,7 +85,7 @@ function renderBoard(){
let h2 = `<tr>`;
ROLES.forEach(r=>{
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${esc(entry.person.name)}</th>`);
});
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
h2 += `</tr>`;
@@ -146,7 +146,7 @@ function renderBoard(){
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
body += `</td>`;
+34 -343
View File
@@ -1,351 +1,42 @@
// -----------------------------
// Projektübersicht — CSV-Serialisierung & Parsing
// Hinweis: weekToCsv() und parseSessionCsv() sind invers zueinander.
// Das Spaltenlayout darf nicht geändert werden, ohne beide Funktionen anzupassen.
// Projektübersicht — CSV/ZIP-Export
// -----------------------------
// Diese Datei enthält das gesamte CSV-Handling (Serialisieren und Parsen) und
// wird nach js/date-picker.js von beiden HTML-Einstiegspunkten geladen. Sie
// stellt die globalen Funktionen weekToCsv/parseSessionCsv, das Personen-CSV
// (globalPeopleToCsv/parseGlobalPeopleCsv) sowie Helfer wie weekSortKey und
// decodeWeekLabel bereit. Hauptnutzer sind js/store.js (Laden/Speichern aller
// Sitzungen), js/weeks.js (Wochen-Klonen) und js/plan/plan-state.js (savePlan),
// die die CSV-Zeichenketten über js/server.js austauschen.
// Stellt exportCSV() bereit: lädt den ZIP mit den übersichtlichen CSV-Dateien
// der aktuellen Abteilung über GET /export-csv und stößt den Download an.
// Hängt im Seitenmenü von Test.html am Button "CSV Export" und nutzt baseUrl()
// aus js/server.js sowie currentDepartment aus js/globals.js. Der ZIP enthält
// pro logischer Entität eine CSV (Wochen, Personen, Projekte, …), erzeugt vom
// Server (siehe server.js im Projekt-Root).
// filenameForWeek: leitet aus dem Wochenlabel einen Dateinamen ab
// (alle Nicht-Wort-Zeichen werden durch _ ersetzt) und hängt .csv an.
// Parameter: label (String). Rückgabe: String wie 'KW_30___2026.csv'.
function filenameForWeek(label){
return (label.replace(/[^\w]/g,'_'))+'.csv';
}
// csvEscape: maskiert ein CSV-Feld, falls es Sonderzeichen (;, ", Zeilenumbruch) enthält
// Parameter: v (beliebiger Wert). Rückgabe: String, bei Bedarf in Anführungszeichen.
function csvEscape(v){
const s = (v === null || v === undefined ? '' : String(v));
if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"';
return s;
}
// globalPeopleToCsv: serialisiert die globalen Personen (aller Abteilungen) als CSV-Text
// Parameter: people (Array von Person-Objekten). Rückgabe: UTF-8-BOM-CSV-String.
function globalPeopleToCsv(people){
const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']];
people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => {
const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : '';
rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]);
});
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
}
// parseGlobalPeopleCsv: parst den _people.csv-Inhalt zurück in Person-Objekte
// Parameter: text (String). Rückgabe: Array von Person-Objekten (mit stabiler ID).
function parseGlobalPeopleCsv(text){
if(!text) return [];
const rows = parseCsvText(text);
if(!rows.length) return [];
const people = [];
rows.slice(1).forEach(row => {
const name = (row[0] || '').trim();
if(!name) return;
const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean);
const fullName = (row[2] || '').trim() || name;
const email = (row[3] || '').trim();
let absences = [];
if(row[4]){
// exportCSV: fordert den CSV-Export der aktuellen Abteilung an und lädt ihn
// als ZIP-Datei herunter. Kein Parameter, keine Rückgabe; Fehler werden über
// ein Alert gemeldet.
async function exportCSV(){
try {
const dept = currentDepartment || 'ELT';
const res = await fetch(baseUrl() + '/export-csv?department=' + encodeURIComponent(dept));
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const parsed = JSON.parse(row[4]);
if(Array.isArray(parsed)) absences = parsed;
} catch(e) {
// Ignoriere fehlerhafte JSON-Daten
}
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences });
});
return people;
}
const blob = await res.blob();
const filename = 'Projektuebersicht_' + dept + '_'
+ new Date().toISOString().slice(0, 10) + '.zip';
// weekToCsv: serialisiert eine komplette Sitzung (Woche) in das feste CSV-Spaltenlayout
// Parameter: w (Wochen-Objekt mit label, date, locked, people, attendance, projects).
// Rückgabe: UTF-8-BOM-CSV-String. Invers zu parseSessionCsv().
function weekToCsv(w){
// allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal.
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
const rows = [];
// Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung
rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']);
// PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden
// die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist.
// Der volle Name wird immer als dritter Teil gespeichert (auch leer),
// damit er beim Bearbeiten nicht versehentlich verloren geht.
const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`);
rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']);
rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']);
rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']);
w.projects.forEach(p => {
const arr = [p.nr || '', p.name || '', p.status || ''];
allPeople.forEach(entry => {
const s = p.assign[assignKey(entry.person.id, entry.role)];
arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : '');
});
LPHS.forEach(l => {
const v = p.lph[l];
arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : '');
});
arr.push(p.group || '');
arr.push(p.order !== undefined ? String(p.order) : '');
arr.push(p.plan ? JSON.stringify(p.plan) : '');
arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : '');
arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : '');
rows.push(arr);
});
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
}
// parseCsvText: entfernt ein (mehrfach kodiertes) BOM und zerlegt den Text in Zeilen-Arrays
// Parameter: text (String). Rückgabe: Array aus Zeilen, jede ein Feld-Array.
function parseCsvText(text){
// Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem
// literalen "" (dreifach kodiert). Beides am Anfang entfernen.
return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine);
}
// Toleranter CSV-Zeilenparser. Versteht sauber gequotetes CSV (wie es
// csvEscape/weekToCsv erzeugen). Semikolons innerhalb eines quoted Feldes sind
// Feldinhalt und kein Trennzeichen – so bleibt weekToCsv()/parseSessionCsv()
// auch bei Status-Texten, die mit ";" enden, invers.
// parseCsvLine: zerlegt eine einzelne CSV-Zeile in ein Feld-Array
// Parameter: line (String). Rückgabe: Array der Felder.
function parseCsvLine(line){
const result = [];
let current = '';
let inQuotes = false;
let hasUnclosedQuotes = false;
let i = 0;
while(i < line.length){
const c = line[i];
const n = line[i+1];
if(inQuotes){
if(c === '"' && n === '"'){
current += '"'; i += 2;
} else if(c === '"' && (n === ';' || n === undefined)){
result.push(current);
current = '';
inQuotes = false;
i += (n === ';' ? 2 : 1);
} else {
current += c; i++;
}
} else {
if(c === ';'){
result.push(current);
current = ''; i++;
} else if(c === '"'){
inQuotes = true; i++;
} else {
current += c; i++;
}
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('CSV-Export fehlgeschlagen:', err);
alert('CSV-Export fehlgeschlagen: ' + (err.message || err));
}
if(inQuotes) hasUnclosedQuotes = true;
result.push(current);
if(hasUnclosedQuotes){
console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80));
}
return result;
}
// decodeWeekLabel: rekonstruiert das Wochenlabel aus einem Dateinamen
// (ersetzt _ durch Leerzeichen und ergänzt das Layout "KW XX / YYYY").
// Parameter: filename (String). Rückgabe: Label-String.
function decodeWeekLabel(filename){
const base = filename.replace(/\.csv$/i,'');
let label = base.replace(/_+/g,' ').trim();
if(/^KW \d+ \d+$/.test(label)){
label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2');
}
return label || base;
}
// Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B.
// "KW 9 / 2026" vor "KW 10 / 2026" kommt.
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
function weekSortKey(label){
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
if(m){
const kw = m[1].padStart(2,'0');
return `${m[2]}-${kw}`;
}
return String(label || '').toLowerCase();
}
// parseSessionCsv: parst die CSV-Zeilen einer Sitzung zurück in ein Wochen-Objekt
// Parameter: filename (String), rows (Array von Feld-Arrays aus parseCsvText).
// Rückgabe: Wochen-Objekt {id, label, date, people, projects, attendance, locked}
// oder null bei leerer Eingabe. Invers zu weekToCsv().
function parseSessionCsv(filename, rows){
if(!rows || rows.length === 0) return null;
let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2;
let locked = false;
let metaPeopleRow = null;
if(rows[0][0] === 'WOCHE'){
label = rows[0][1] || decodeWeekLabel(filename);
date = rows[0][2] || '';
locked = (rows[0][3] || '').trim() === '1';
if(rows[1] && rows[1][0] === 'PERSONEN'){
metaPeopleRow = rows[1];
headerRowIdx = 2; attendanceRowIdx = 3;
} else {
headerRowIdx = 1; attendanceRowIdx = 2;
}
} else {
label = decodeWeekLabel(filename);
date = '';
}
projectStartIdx = attendanceRowIdx + 1;
const header = rows[headerRowIdx];
if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} };
const lphStart = header.indexOf('LPH 1');
const groupIdx = header.indexOf('Gruppe');
const orderIdx = header.indexOf('Index');
const planIdx = header.indexOf('Plan');
const aufwandIdx = header.indexOf('Aufwand');
const aktiveIdx = header.indexOf('Aktive Phasen');
const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart);
const people = [];
const colMap = [];
const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : [];
const personByName = {};
const nameOccurrences = {};
peopleCols.forEach((name, idx) => {
const n = (name || '').trim();
if(!n){ colMap.push(null); return; }
let roles = ['Planung'];
let fullName = n;
if(personMeta[idx]){
const parts = String(personMeta[idx]).split('|');
const rawRoles = parts[1] || '';
const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x));
if(parsedRoles.length > 0) roles = parsedRoles;
// Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen
// im Namen möglich. Leere Strings werden bewusst übernommen.
if(parts.length >= 3) fullName = parts.slice(2).join('|').trim();
} else {
const occurrence = nameOccurrences[n] || 0;
roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]];
nameOccurrences[n] = occurrence + 1;
}
// Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach
// dargestellt, soll aber im Datenmodell nur einmal existieren.
let person = personByName[n];
if(!person){
person = { id: personIdForName(n), name: n, fullName: fullName, role: [] };
personByName[n] = person;
people.push(person);
}
// Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist
// hier die führende Datenquelle für den vollständigen Namen.
person.fullName = fullName;
// Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle
// wird für die Zuordnung über colMap verwendet.
roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); });
colMap.push({ person, role: roles[0] });
});
const projects = [];
for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){
const row = rows[rIdx];
if(row.length < 4) continue;
const nr = row[0] || '';
const name = row[1] || '';
const status = row[2] || '';
if(!nr.trim() && !name.trim()) continue;
const assign = {};
peopleCols.forEach((_, i) => {
const entry = colMap[i];
if(!entry) return;
const v = (row[3+i] || '').trim();
const key = assignKey(entry.person.id, entry.role);
if(v === 'X') assign[key] = 1;
else if(v === '(X)') assign[key] = 2;
});
const lph = {};
if(lphStart !== -1){
LPHS.forEach((l, j) => {
const raw = row[lphStart + j];
if(raw !== undefined && raw !== ''){
const num = String(raw).replace(/%/g,'');
if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num;
}
});
}
let group = 'Aktive Projekte';
if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group;
else if(row.length > 3) group = row[row.length - 1].trim() || group;
let order = rIdx - projectStartIdx;
if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){
const parsed = parseInt(row[orderIdx], 10);
if(!isNaN(parsed)) order = parsed;
}
let plan = null;
if(planIdx >= 0 && row[planIdx]){
try {
const parsed = JSON.parse(row[planIdx]);
if(parsed && typeof parsed === 'object') plan = parsed;
} catch(e) {
console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e);
}
}
let aufwandProLP;
if(aufwandIdx >= 0 && row[aufwandIdx]){
try {
const parsed = JSON.parse(row[aufwandIdx]);
if(parsed && typeof parsed === 'object') aufwandProLP = parsed;
} catch(e) {
console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e);
}
}
let aktuelleLP;
if(aktiveIdx >= 0 && row[aktiveIdx]){
try {
const parsed = JSON.parse(row[aktiveIdx]);
if(Array.isArray(parsed)) aktuelleLP = parsed;
else if(typeof parsed === 'string') aktuelleLP = [parsed];
} catch(e) {
console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e);
}
}
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP });
}
const attendance = {};
const attRow = rows[attendanceRowIdx];
if(attRow && attRow[0] === 'ANWESENHEIT'){
peopleCols.forEach((_, i) => {
const entry = colMap[i];
if(entry && (attRow[2+i] || '').trim() === 'X'){
attendance[entry.person.id] = true;
}
});
}
return { id: uid(), label, date, people, projects, attendance, locked };
}
+2 -2
View File
@@ -335,8 +335,8 @@ function buildGanttHtml(projects, start, end, options = {}){
function openProjectPlan(pid){
const w = currentWeek(); if(!w) return;
const p = w.projects.find(x => x.id === pid); if(!p) return;
// IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt).
// Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
// Projekt-IDs sind in der DB stabil, aber die Plan-Seite ist eine separate
// Route. Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
+67
View File
@@ -0,0 +1,67 @@
// -----------------------------
// Projektübersicht — Datenbank-Import (Backup-Wiederherstellung)
// -----------------------------
// Stellt importDb() bereit: wählt über einen Datei-Dialog eine mit "DB Backup"
// erzeugte .db-Datei aus, lädt sie als rohen Binär-Body an POST /import und
// lädt nach erfolgreicher Wiederherstellung die Seite neu. Da der Import den
// kompletten Datenbestand ersetzt, ist er passwortgeschützt (gleiche Logik wie
// Wochen-Löschung über requestedAction/isAuthenticated, siehe js/tabs-auth.js
// und js/weeks.js) und wird zusätzlich im Browser per Bestätigungsdialog
// abgesichert.
// importDb: öffnet bei Bedarf das Passwort-Modal (requestedAction 'importDb')
// und startet danach den Datei-Dialog für das Backup. Parameter `skipAuth=true`
// überspringt die Passwortabfrage (wird von confirmPassword() genutzt).
// Keine Rückgabe.
function importDb(skipAuth = false){
if(!skipAuth && !isAuthenticated){
requestedAction = { type: 'importDb' };
document.getElementById('passwordModal').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
return;
}
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\nAlle aktuellen Daten werden ersetzt.')){
requestedAction = null;
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.db,application/octet-stream,application/x-sqlite3';
input.onchange = () => {
const file = input.files && input.files[0];
if(!file){
requestedAction = null;
return;
}
uploadBackup(file);
};
input.click();
requestedAction = null;
}
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
// POST /import und informiert über das Ergebnis. Bei Erfolg wird die Seite neu
// geladen, damit das Frontend den wiederhergestellten Datenbestand zeigt.
async function uploadBackup(file){
try {
const res = await fetch(baseUrl() + '/import', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file
});
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
alert('Datenbank-Backup erfolgreich wiederhergestellt.\nDie Seite wird neu geladen.');
window.location.reload();
} catch(err){
console.error('DB-Import fehlgeschlagen:', err);
alert('DB-Import fehlgeschlagen: ' + (err.message || err));
}
}
+8 -8
View File
@@ -45,7 +45,7 @@ function addPerson(){
document.getElementById('newPersonFullName').value='';
document.getElementById('newPersonEmail').value='';
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
@@ -76,7 +76,7 @@ function delPerson(id){
w.people = (w.people || []).filter(p=>p.id!==id);
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
@@ -132,7 +132,7 @@ function editPerson(id,field,val){
delete w.attendance[id];
}
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
return;
}
@@ -146,7 +146,7 @@ function editPerson(id,field,val){
const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson) localPerson[field] = val;
}
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt
@@ -172,7 +172,7 @@ function togglePersonRole(id, role, checked){
if(globalPerson) updateRoles(globalPerson);
if(localPerson) updateRoles(localPerson);
save(); renderPeople(); renderBoard();
save({people:true}); renderPeople(); renderBoard();
}
// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf
@@ -324,7 +324,7 @@ function reorderPeople(){
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople;
}
save();
save({people:true});
}
}
@@ -348,7 +348,7 @@ function addAbsence(personId){
if(!Array.isArray(person.absences)) person.absences = [];
person.absences.push({ id: uid(), start, end, type, note });
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
save();
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
@@ -361,7 +361,7 @@ function deleteAbsence(personId, absenceId){
const person = people.find(p => p.id === personId);
if(!person || !person.absences) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
save();
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
+7 -3
View File
@@ -114,16 +114,20 @@
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const todoIds = todos.map(t => t.id);
const milestoneIds = milestones.map(m => m.id);
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== todoIds.length) return;
if(milestoneRowIds.length !== milestoneIds.length) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen
const newTodos = [];
+46 -25
View File
@@ -66,20 +66,17 @@ window.PlanApp = {
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
// anderen plan-Module.
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
// falls ein Button-Element fehlt.
function bindToolbarButtons(){
const addTodoBtn = document.getElementById('addTodoBtn');
if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo());
const autoScheduleBtn = document.getElementById('autoScheduleBtn');
if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos());
const criticalPathBtn = document.getElementById('criticalPathBtn');
if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath());
}
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
// Abteilung über die Server-API, sortiert die Wochen chronologisch und
// sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen
// der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage().
// Abteilung über die Server-API (JSON-Dokumente), sortiert die Wochen
// chronologisch und sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt
// Fehlermeldungen der Reihenfolge nach im Seitenkopf an und startet sonst
// renderPlanPage().
function loadPlanData(){
if(!window.fetch){
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
@@ -90,12 +87,15 @@ window.PlanApp = {
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
db = { weeks: [], currentWeek: null, globalPeople: {} };
db.globalPeople[app.department] = parseGlobalPeopleCsv(data.people);
const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
parsed.forEach(s => {
const w = parseSessionCsv(s.filename, s.rows);
if(w) db.weeks.push(w);
});
db.globalPeople[app.department] = Array.isArray(data.people) ? data.people : [];
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
app.week = db.weeks.find(w => w.label === app.weekLabel);
if(!app.week){
@@ -176,22 +176,43 @@ window.PlanApp = {
}).join('');
}
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
let savePlanRunning = false;
let savePlanQueued = false;
function savePlan(){
if(!app.week || !app.project) return;
const filename = filenameForWeek(app.week.label);
const csv = weekToCsv(app.week);
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
if(savePlanRunning){ savePlanQueued = true; return; }
savePlanRunning = true;
sendWeekToServer(app.week)
.then(res => {
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
})
.catch(err => {
if(err && err.conflict){
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
} else {
console.error('Speichern fehlgeschlagen:', err);
}
})
.finally(() => {
savePlanRunning = false;
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
});
}
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
// _people.csv auf den Server, aber nur wenn sie nicht leer ist.
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
// das Löschen der letzten Person erhalten bleibt.
function saveGlobalPeople(){
const people = db.globalPeople[app.department] || [];
if(people.length > 0){
const peopleCsv = globalPeopleToCsv(people);
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
}
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
}
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
+4 -3
View File
@@ -62,6 +62,7 @@ function editProjectActiveLP(id, value){
function renderProjects(){
const w = currentWeek();
if(!w) return;
const locked = isWeekLocked(w);
let html="";
const groups = {};
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
@@ -71,10 +72,10 @@ function renderProjects(){
sortGroups(Object.keys(groups)).forEach(g=>{
html += `<div class="section-title">${esc(g)}</div>`;
html += `<div class="projects-sortable" data-group="${g}" ondragover="handleDragOver(event)" ondrop="handleDrop(event)">`;
html += `<div class="projects-sortable" data-group="${g}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
groups[g].forEach(p=>{
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)">
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>
html += `<div class="person-list-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>`}
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
<select onchange="editProjectField('${p.id}','group',this.value)">
+51 -17
View File
@@ -1,13 +1,15 @@
// -----------------------------
// Projektübersicht — Server-Kommunikation (CSV-API)
// Projektübersicht — Server-Kommunikation (JSON-API)
// -----------------------------
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
// server.js im Projekt-Root) und wird nach js/csv.js von beiden
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
// sendCsvToServer und deleteCsvFromServer bereit. Genutzt wird sie von
// js/store.js (syncAllToServer, Speichern/Löschen aller Wochen),
// js/weeks.js (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt
// direkt die aktuelle Woche über sendCsvToServer).
// sendWeekToServer, sendPeopleToServer und deleteWeekFromServer bereit.
// Genutzt wird sie von js/store.js (syncAllToServer, Speichern aller Wochen
// und der Personenliste), js/weeks.js (Wochen-Löschung) und
// js/plan/plan-state.js (savePlan schreibt direkt die aktuelle Woche über
// sendWeekToServer). Die Daten werden als strukturierte JSON-Objekte an die
// SQLite-API des Servers übergeben (keine CSV-Strings mehr).
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist.
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
@@ -31,24 +33,56 @@ function blobToDataUrl(blob){
});
}
// sendCsvToServer: POSTet eine Speicherwoche im CSV-Format an <baseUrl>/save-csv
// Parameter: filename (String), csvContent (String). Rückgabe: Promise mit Server-Antwort.
function sendCsvToServer(filename, csvContent){
// sendWeekToServer: POSTet ein komplettes Wochen-Objekt (JSON) an
// <baseUrl>/save-week. Parameter: week (Objekt). Rückgabe: Promise mit
// Server-Antwort. `baseUpdatedAt` übermittelt die Revision, auf der die
// lokalen Daten beruhen; meldet der Server 409 (conflict), wird ein Error
// mit `err.conflict = true` und `err.updatedAt` geworfen, damit der Aufrufer
// den Konflikt anzeigen kann.
function sendWeekToServer(week){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-csv',{
return fetch(baseUrl() + '/save-week',{
method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({filename,csv:csvContent,department:currentDepartment})
body:JSON.stringify({
week,
department:currentDepartment,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
})
}).then(r=>{
return r.json().then(json => {
if(r.ok && json) return json;
if(json && json.conflict){
const err = new Error(json.error || 'Konflikt: Sitzung wurde zwischenzeitlich geändert');
err.conflict = true;
err.label = (week && week.label) || '';
err.updatedAt = json.updatedAt;
throw err;
}
throw new Error('Server HTTP '+r.status);
});
});
}
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
// JSON-Array an <baseUrl>/save-people. Parameter: people (Array).
// Rückgabe: Promise mit Server-Antwort.
function sendPeopleToServer(people){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-people',{
method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({people, department:currentDepartment})
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
}
// deleteCsvFromServer: fordert das Löschen einer Speicherwoche über POST /delete-csv an
// Parameter: filename (String). Rückgabe: Promise mit Server-Antwort.
function deleteCsvFromServer(filename){
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
function deleteWeekFromServer(label){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/delete-csv', {
return fetch(baseUrl() + '/delete-week', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ filename, department: currentDepartment })
body: JSON.stringify({ label, department: currentDepartment })
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
}
}
+173 -42
View File
@@ -1,22 +1,44 @@
// -----------------------------
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln
// -----------------------------
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungs-CSVs
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungen
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
// Sie wird von `Test.html` nach `js/csv.js` und `js/server.js` geladen und stellt
// Die Daten liegen serverseitig in einer SQLite-Datenbank und werden als
// strukturierte JSON-Objekte über die API ausgetauscht (keine CSV-Strings).
// Sie wird von `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`,
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`,
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
// save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server.
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden
// in der Sitzungscsv selbst abgelegt.
function save(){
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// aktuelle Sitzung beziehen. Optionen:
// { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
// { people:true } – zusätzlich die globale Personenliste als geändert markieren
function save(opts = {}){
if(opts.week !== false){
const w = currentWeek();
if(w) dirtyWeeks.add(w.label);
}
if(opts.people) dirtyPeople = true;
scheduleSessionSync();
}
// Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste
// seit dem letzten Sync lokal geändert wurden. Gespeichert wird nur das, was
// sich wirklich geändert hat (statt wie früher immer alle Wochen).
let dirtyWeeks = new Set();
let dirtyPeople = false;
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
// Revisions-Basis und der zweite bekäme einen scheinbaren 409-Konflikt).
// Stattdessen wird ein Nachlauf-Sync eingereiht und nach Abschluss ausgeführt.
let syncInFlight = false;
let syncQueued = false;
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
@@ -25,33 +47,133 @@ function scheduleSessionSync(){
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
}
// syncAllToServer: schreibt die gesamte Datenbank als CSV-Dateien zum Server.
// Zuerst wird die globale Personenliste (`_people.csv`) gespeichert, danach jede
// Sitzung einzeln über `filenameForWeek()`/`weekToCsv()` und `sendCsvToServer()`.
// Gibt nichts zurück; Fehler werden nur per console.error protokolliert.
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// plus – falls markiert – die globale Personenliste zum Server. Konflikte
// (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft
// gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut
// versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){
// Zuerst globale Personenliste speichern
saveGlobalPeopleToServer();
// Dann jede Sitzung speichern
db.weeks.forEach(w => {
const filename = filenameForWeek(w.label);
const csv = weekToCsv(w);
sendCsvToServer(filename, csv)
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); })
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); });
if(syncInFlight){ syncQueued = true; return; }
syncInFlight = true;
// Zuerst globale Personenliste speichern, falls geändert
const jobs = [];
if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
const weekLabels = Array.from(dirtyWeeks);
weekLabels.forEach(label => {
const w = db.weeks.find(x => x.label === label);
if(!w){ dirtyWeeks.delete(label); return; }
jobs.push(sendWeekToServer(w)
.then(res => {
if(res && res.ok){
console.log('Sitzung gespeichert:', w.label);
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt;
dirtyWeeks.delete(label);
} else {
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
}
})
.catch(err => {
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else {
console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
});
Promise.all(jobs).then(() => {
syncInFlight = false;
if(syncQueued){ syncQueued = false; syncAllToServer(); }
});
}
// saveGlobalPeopleToServer: serialisiert die globale Personenliste der aktuellen
// Abteilung per `globalPeopleToCsv()` und speichert sie als `_people.csv`.
// Leere Personenlisten werden übersprungen (kein Schreibvorgang).
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){
if(typeof label === 'string' && label) dirtyWeeks.add(label);
}
// markPeopleDirty: markiert die globale Personenliste als geändert.
function markPeopleDirty(){
dirtyPeople = true;
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
// geänderte Woche meldet (409). Nur diese eine Woche wird vom Server neu
// geladen; andere lokal geänderte Wochen (dirtyWeeks) und die Personenliste
// bleiben unangetastet und gehen nicht verloren.
function handleWeekConflict(week, err){
const label = (week && week.label) || (err && err.label) || '';
alert('Achtung: ' + label + ' wurde zwischenzeitlich von einer anderen Person '
+ 'geändert. Ihre Änderungen an dieser Sitzung wurden nicht gespeichert; '
+ 'die aktuellen Daten dieser Sitzung werden geladen.');
if(label) reloadWeekFromServer(label);
else load();
}
// reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
const requestedDepartment = currentDepartment;
fetch(baseUrl() + '/load-all' + departmentParam())
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
if(currentDepartment !== requestedDepartment) return;
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
.find(w => w && w.label === label);
const idx = db.weeks.findIndex(w => w.label === label);
if(idx >= 0){
if(fresh){
// Felder absichern (wie in load()), damit keine kaputten Daten crashen
if(!Array.isArray(fresh.people)) fresh.people = [];
if(!Array.isArray(fresh.projects)) fresh.projects = [];
if(!fresh.attendance) fresh.attendance = {};
db.weeks[idx] = fresh;
} else {
db.weeks.splice(idx, 1);
}
// Wurde die aktuelle Woche entfernt, auf die letzte verbleibende wechseln
if(!db.weeks.some(w => w.id === db.currentWeek)){
db.currentWeek = db.weeks.length ? db.weeks[db.weeks.length - 1].id : null;
}
}
renderAll();
showTab(currentTab);
})
.catch(err => {
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
load();
});
}
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
// Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
// wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei
// Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt
// er erhalten (Retry beim nächsten Sync). Rückgabe: Promise.
function saveGlobalPeopleToServer(){
const people = db.globalPeople[currentDepartment] || [];
if(people.length === 0) return;
const csv = globalPeopleToCsv(people);
sendCsvToServer('_people.csv', csv)
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
return sendPeopleToServer(people)
.then(res => {
if(res && res.ok){
dirtyPeople = false;
console.log('Globale Personenliste gespeichert');
} else {
console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler');
}
})
.catch(err => {
console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err);
// dirtyPeople bleibt true -> der nächste Sync versucht es erneut.
});
}
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
@@ -62,8 +184,10 @@ function switchDepartment(dept){
if(!DEPARTMENTS[dept]) return;
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
// CSV der falschen Abteilung schreibt.
// Daten der falschen Abteilung schreibt.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
currentDepartment = dept;
saveUiState();
updateRouteUrl(dept, currentTab);
@@ -84,12 +208,17 @@ function switchDepartment(dept){
load();
}
// load: liest alle CSV-Dateien der aktuellen Abteilung über `/load-all` vom
// Server ein, parst Personen und Sitzungen (mit `parseSessionCsv()`) und baut
// daraus `db.weeks` auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()`
// an und rendert die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder
// fehlenden Daten werden Beispieldaten über `seed()` geladen.
// load: liest alle Sitzungen und die globale Personenliste der aktuellen
// Abteilung über `/load-all` als JSON vom Server und baut daraus `db.weeks`
// auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()` an und rendert
// die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden
// Daten werden Beispieldaten über `seed()` geladen.
function load(){
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
seed(); return;
@@ -106,18 +235,20 @@ function load(){
.then(data => {
if(currentDepartment !== requestedDepartment) return;
// Globale Personenliste laden
db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people);
db.globalPeople[currentDepartment] = Array.isArray(data.people) ? data.people : [];
const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
db.weeks = [];
parsedSessions.forEach(s => {
const w = parseSessionCsv(s.filename, s.rows);
if(w) db.weeks.push(w);
});
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
// Felder absichern, damit alte/kaputte Dokumente nicht crashen
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
if(db.weeks.length === 0){
console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.');
console.warn('Server lieferte 0 Sitzungen. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob die SQLite-Datenbank sessions/projektuebersicht.db existiert.');
seed();
}
else {
+1
View File
@@ -59,6 +59,7 @@ function confirmPassword(){
if(action?.type==='tab') showTab(action.tab, true);
else if(action?.type==='deleteWeek') deleteWeek(true);
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
else if(action?.type==='importDb') importDb(true);
}, 100);
document.getElementById('passwordModal').style.display='none';
} else {
+14 -3
View File
@@ -5,7 +5,7 @@
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
// u. a. js/csv.js, js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
// u. a. 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.
// esc: einfache Escape-Funktion für Ausgabe in HTML
@@ -46,8 +46,7 @@ function sortGroups(arr){
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt.
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
// Liegt im gemeinsamen Modul, weil auch csv.js (weekToCsv für savePlan auf der
// Projektplan-Seite) und pdf.js darauf zugreifen.
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
function peopleByRole(target){
const m = {}; ROLES.forEach(r=>m[r]=[]);
const people = [];
@@ -73,6 +72,18 @@ function peopleByRole(target){
return m;
}
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel,
// sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt.
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
function weekSortKey(label){
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
if(m){
const kw = m[1].padStart(2,'0');
return `${m[2]}-${kw}`;
}
return String(label || '').toLowerCase();
}
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
// Zuordnungen auch nach einem Neuladen stabil bleiben.
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
+19 -13
View File
@@ -31,9 +31,11 @@ function updateWeekDate(){
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`), speichert und
// rendert die Ansicht neu. Keine Rückgabe.
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); }
// switchWeek: setzt die aktuell gewählte Wochen-ID (`id`) und rendert die
// Ansicht neu. Es werden keine Daten geändert, daher wird nicht gespeichert
// (eine save() würde die Woche nur als dirty markieren und unnötig senden).
// Keine Rückgabe.
function switchWeek(id){ db.currentWeek = id; renderAll(); }
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
@@ -82,7 +84,11 @@ function applyAutoLock(){
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const latestId = sorted[sorted.length - 1].id;
db.weeks.forEach(w => {
const wasLocked = !!w.locked;
w.locked = (w.id !== latestId);
// Nur tatsächlich geänderte Lock-Zustände übertragen (beim bloßen Laden
// sind die Werte identisch und es wird nichts gesendet).
if(wasLocked !== w.locked) markWeekDirty(w.label);
});
}
@@ -124,9 +130,9 @@ function unlockCurrentWeek(skipAuth = false){
}
// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1
// vorhanden) inkl. ihrer CSV-Datei auf dem Server. Parameter `skipAuth=true`
// überspringt die Passwortabfrage. Danach wird die letzte verbleibende Woche
// aktiviert und neu gerendert. Keine Rückgabe.
// vorhanden) auf dem Server. Parameter `skipAuth=true` überspringt die
// Passwortabfrage. Danach wird die letzte verbleibende Woche aktiviert und neu
// gerendert. Keine Rückgabe.
function deleteWeek(skipAuth = false){
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
if(!skipAuth && !isAuthenticated){
@@ -138,13 +144,13 @@ function deleteWeek(skipAuth = false){
}
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
const current = currentWeek();
const filename = current ? filenameForWeek(current.label) : null;
if(filename){
console.log('Lösche CSV-Datei auf Server:', filename);
deleteCsvFromServer(filename).then(res=>{
if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); }
else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
}).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); });
if(current){
const label = current.label;
console.log('Lösche Woche auf Server:', label);
deleteWeekFromServer(label).then(res=>{
if(res && res.ok){ console.log('Woche gelöscht:', label); }
else { alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
}).catch(err=>{ alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err)); });
}
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
-3279
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -6,7 +6,7 @@
in js/gantt.js in einem neuen Tab geöffnet und per Deep-Link-Route
"/<Abteilung>/Projektplan/<Projektname>" adressiert; Woche und Projekt-Nr.
kommen als URL-Parameter (weekLabel, projNr). Sie lädt styles.css und ausschließlich die gemeinsamen
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/csv.js, js/server.js
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/server.js
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
@@ -318,7 +318,6 @@
<script src="/js/utils.js?v=5"></script>
<script src="/js/ui.js?v=5"></script>
<script src="/js/date-picker.js?v=5"></script>
<script src="/js/csv.js?v=5"></script>
<script src="/js/server.js?v=5"></script>
<script src="/js/gantt.js?v=5"></script>
<script src="/js/plan/plan-state.js?v=5"></script>
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1161,8 +1161,12 @@ th.role-bauüberwachung { color: var(--text); }
.legend-item.locked-legend-item { opacity: .7; }
#boardLegend {
margin-top: 16px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: 6px;
font-size: 12px;
}
.swatch {