351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — CSV-Serialisierung & Parsing
|
||
// Hinweis: weekToCsv() und parseSessionCsv() sind invers zueinander.
|
||
// Das Spaltenlayout darf nicht geändert werden, ohne beide Funktionen anzupassen.
|
||
// -----------------------------
|
||
// 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.
|
||
|
||
// 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]){
|
||
try {
|
||
const parsed = JSON.parse(row[4]);
|
||
if(Array.isArray(parsed)) absences = parsed;
|
||
} catch(e) {
|
||
// Ignoriere fehlerhafte JSON-Daten
|
||
}
|
||
}
|
||
people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences });
|
||
});
|
||
return people;
|
||
}
|
||
|
||
// 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++;
|
||
}
|
||
}
|
||
}
|
||
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 };
|
||
} |