alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+353
View File
@@ -0,0 +1,353 @@
// -----------------------------
// Projektplan — Dateien (Upload, Freigabe, Kommentare)
// -----------------------------
// Dieses Modul stellt den Bereich "Dateien" des Projektbereichs bereit
// (Upload-Maske und Dateiliste, siehe js/plan/plan-sections.js; eine
// Kompakt-Karte im Dashboard, #planFilesSummary, zeigt die zuletzt
// hochgeladene Datei und ausstehende Prüfungen und springt per Klick über
// openFilesPanel() in diesen Bereich): Upload (roher
// Binär-Body wie js/import.js), Anzeige aller Dateien eines Projekts samt
// Prüferstatus, Genehmigen-/Ablehnen-Aktionen für den eingeloggten Prüfer
// sowie einen einfachen Kommentar-Thread je Datei. Dateien werden nur noch
// über project_nr allein mit dem Projekt verknüpft (NICHT project_id — siehe
// server.js-Abschnitt "Datei-Upload, Freigabe, Kommentare"), daher ist der
// Upload-Bereich deaktiviert und die Liste zeigt nur einen Hinweis, solange
// das aktuelle Projekt keine Projektnummer (app.project.nr) hat.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js) für app.project, currentUser (js/auth.js) für die
// Sichtbarkeit der Genehmigen-/Ablehnen-Buttons.
(function(){
const app = window.PlanApp;
// filesCache: zuletzt geladene Dateiliste (für Re-Renders nach Aktionen,
// ohne jedes Mal neu zu fetchen); openComments: Set der Datei-IDs, deren
// Kommentar-Thread gerade aufgeklappt ist.
let filesCache = [];
const openComments = new Set();
// statusLabel/statusClass: deutsche Anzeige- bzw. CSS-Klasse für den
// Datei-/Prüfstatus.
function statusLabel(status){
if(status === 'approved') return 'Freigegeben';
if(status === 'rejected') return 'Abgelehnt';
return 'Ausstehend';
}
function statusClass(status){
if(status === 'approved') return 'approved';
if(status === 'rejected') return 'rejected';
return 'pending';
}
// formatDateTimeDE: formatiert einen ISO-Zeitstempel als deutsches
// Datum+Uhrzeit (Fallback: leerer String bei ungültigem Wert).
function formatDateTimeDE(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d.getTime())) return '';
return formatDateDE(d) + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
}
// initPlanFiles: initialisiert den Dateien-Bereich beim Laden der Seite
// (Prüfer-Checkboxen befüllen, Dateiliste laden) — Nachfolger des früheren
// openFilesModal(), seit der Bereich fest auf der Seite sichtbar ist statt
// in einem Modal. Wird von renderPlanPage() (plan-state.js) aufgerufen.
window.initPlanFiles = function(){
const hasNr = !!(app.project && app.project.nr);
const hint = document.getElementById('filesNoProjectHint');
const uploadSection = document.getElementById('filesUploadSection');
if(hint) hint.style.display = hasNr ? 'none' : '';
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
const fileInput = document.getElementById('fileUploadInput');
if(fileInput) fileInput.value = '';
if(hasNr) loadReviewerCheckboxes();
renderProjectFiles();
};
// openFilesPanel: springt in den Bereich "Dateien" des Projektbereichs
// (js/plan/plan-sections.js), in dem Upload-Maske und Dateiliste stehen.
// Früher öffnete die Funktion ein eigenes Modal; der Name bleibt, weil die
// Kompakt-Karte "Dateien & Freigaben" im Dashboard darauf verweist.
window.openFilesPanel = function(){
if(typeof showPlanSection === 'function') showPlanSection('dateien');
};
// loadReviewerCheckboxes: lädt alle aktiven Benutzerkonten (GET /users,
// nicht die Roster-Personen des Projekts — Prüfer müssen echte Logins
// sein) und baut die Mehrfachauswahl-Checkbox-Liste (gleiches Muster wie
// todoEditAssignees in plan-modals.js).
function loadReviewerCheckboxes(){
const container = document.getElementById('fileUploadReviewers');
if(!container) return;
container.innerHTML = '<span class="hint">Lade Benutzer…</span>';
fetch(baseUrl() + '/users')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const users = Array.isArray(data.users) ? data.users : [];
if(users.length === 0){
container.innerHTML = '<span class="hint">Keine Benutzer vorhanden.</span>';
return;
}
// Das eigene Konto herausfiltern: Der Server entfernt den Uploader
// still aus der Prüferliste — wer nur sich selbst ankreuzte, bekam
// deshalb die verwirrende Meldung "Mindestens ein Prüfer erforderlich".
const selectable = users.filter(u => !(typeof currentUser !== 'undefined' && currentUser && u.id === currentUser.id));
if(selectable.length === 0){
container.innerHTML = '<span class="hint">Keine weiteren Benutzer vorhanden, die prüfen könnten.</span>';
return;
}
container.innerHTML = selectable.map(u =>
`<label class="todo-depends-item"><input type="checkbox" value="${esc(String(u.id))}"> ${esc(u.displayName || u.username)}</label>`
).join('');
})
.catch(err => {
container.innerHTML = '<span class="hint">Benutzer konnten nicht geladen werden.</span>';
console.error('Benutzerliste laden fehlgeschlagen:', err);
});
}
// submitFileUpload: liest die gewählte Datei und die angehakten Prüfer,
// schickt die Datei als rohen Binär-Body (wie js/import.js) an
// POST /project-files und lädt danach die Liste neu.
window.submitFileUpload = function(){
if(!app.project || !app.project.nr){ alert('Dieses Projekt hat keine Projektnummer.'); return; }
const fileInput = document.getElementById('fileUploadInput');
const file = fileInput && fileInput.files && fileInput.files[0];
if(!file){ alert('Bitte eine Datei auswählen.'); return; }
const reviewersContainer = document.getElementById('fileUploadReviewers');
const reviewerIds = reviewersContainer
? Array.from(reviewersContainer.querySelectorAll('input:checked')).map(cb => cb.value)
: [];
if(reviewerIds.length === 0){ alert('Bitte mindestens einen Prüfer auswählen.'); return; }
const url = baseUrl() + '/project-files'
+ '?projectNr=' + encodeURIComponent(app.project.nr)
+ '&projectName=' + encodeURIComponent(app.project.name || '')
+ '&filename=' + encodeURIComponent(file.name)
+ '&reviewers=' + reviewerIds.join(',');
fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Upload fehlgeschlagen.');
if(fileInput) fileInput.value = '';
if(reviewersContainer) reviewersContainer.querySelectorAll('input:checked').forEach(cb => cb.checked = false);
app.showToast('Datei hochgeladen.');
renderProjectFiles();
})
.catch(err => {
console.error('Datei-Upload fehlgeschlagen:', err);
alert('Datei-Upload fehlgeschlagen: ' + (err.message || err));
});
};
// renderProjectFiles: lädt die Dateiliste des aktuellen Projekts
// (GET /project-files?projectNr=) und rendert sie in #filesListContainer.
// Ohne Projektnummer wird nur ein Hinweistext gezeigt.
window.renderProjectFiles = function(){
const container = document.getElementById('filesListContainer');
if(!container) return;
if(!app.project || !app.project.nr){
container.innerHTML = '<span class="hint">Dieses Projekt hat keine Projektnummer und kann daher keine Dateien erhalten.</span>';
const summaryEl = document.getElementById('planFilesSummary');
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
return;
}
container.innerHTML = '<span class="hint">Lade Dateien…</span>';
const url = baseUrl() + '/project-files'
+ '?projectNr=' + encodeURIComponent(app.project.nr);
fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
filesCache = Array.isArray(data.files) ? data.files : [];
renderFilesList();
})
.catch(err => {
container.innerHTML = '<span class="hint">Dateien konnten nicht geladen werden.</span>';
console.error('Dateiliste laden fehlgeschlagen:', err);
});
};
// renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"-
// Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in
// Test.html) aus filesCache — Name der zuletzt hochgeladenen Datei
// (Server liefert /project-files bereits nach created_at DESC sortiert,
// filesCache[0] ist also die neueste) sowie einen Hinweis auf noch
// ausstehende Prüfungen.
function renderFilesSummary(){
const summaryEl = document.getElementById('planFilesSummary');
if(!summaryEl) return;
if(filesCache.length === 0){
summaryEl.innerHTML = '<span class="hint">Noch keine Dateien.</span>';
return;
}
const pendingCount = filesCache.filter(f => f.status === 'pending').length;
const chip = pendingCount > 0
? `<span class="plan-mini-chip warn">${pendingCount} ausstehend</span>`
: '<span class="plan-mini-chip">Alle geprüft</span>';
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(filesCache[0].originalFilename)}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
}
// renderFilesList: rendert filesCache in #filesListContainer (Name,
// Uploader, Datum, Status-Badge, Prüferstatus je Prüfer, Kommentaranzahl,
// Download-Link, Genehmigen-/Ablehnen-Buttons für den aktuellen Prüfer,
// aufklappbarer Kommentar-Thread).
function renderFilesList(){
const container = document.getElementById('filesListContainer');
if(!container) return;
renderFilesSummary();
if(filesCache.length === 0){
container.innerHTML = '<span class="hint">Noch keine Dateien für dieses Projekt hochgeladen.</span>';
return;
}
container.innerHTML = filesCache.map(f => {
const myReview = currentUser ? (f.reviewers || []).find(r => r.userId === currentUser.id) : null;
const canReview = myReview && myReview.decision === 'pending' && f.status === 'pending';
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin');
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
const canDelete = isAdmin || (isUploader && f.status === 'pending');
const reviewerChips = (f.reviewers || []).map(r =>
`<span class="file-reviewer-chip ${esc(statusClass(r.decision))}">${esc(r.displayName)}: ${esc(statusLabel(r.decision))}</span>`
).join('');
const downloadUrl = baseUrl() + '/project-files/' + encodeURIComponent(f.id) + '/download';
return `
<div class="file-item" data-file-id="${esc(f.id)}">
<div class="file-item-header">
<a href="${esc(downloadUrl)}" class="file-item-name">${esc(f.originalFilename)}</a>
<span class="file-status-badge ${esc(statusClass(f.status))}">${esc(statusLabel(f.status))}</span>
</div>
<div class="file-item-meta">Hochgeladen von ${esc(f.uploadedBy || '–')} am ${esc(formatDateTimeDE(f.createdAt))}</div>
<div class="file-reviewer-list">${reviewerChips}</div>
<div class="file-item-actions">
${canReview ? `<button class="small" onclick="reviewProjectFile('${esc(f.id)}','approved')">Genehmigen</button>
<button class="small danger" onclick="reviewProjectFile('${esc(f.id)}','rejected')">Ablehnen</button>` : ''}
<button class="small secondary" onclick="toggleFileComments('${esc(f.id)}')">Kommentare (${f.commentCount || 0})</button>
${canDelete ? `<button class="small danger" onclick="deleteProjectFile('${esc(f.id)}')">Löschen</button>` : ''}
</div>
<div class="file-comments" id="fileComments_${esc(f.id)}" style="display:${openComments.has(f.id) ? '' : 'none'}"></div>
</div>`;
}).join('');
// Kommentar-Threads, die vorher aufgeklappt waren, direkt neu laden.
openComments.forEach(fileId => {
if(filesCache.some(f => f.id === fileId)) loadFileComments(fileId);
});
}
// reviewProjectFile: sendet die Freigabe-Entscheidung des aktuellen
// Benutzers für eine Datei und lädt die Liste danach neu.
window.reviewProjectFile = function(fileId, decision){
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ decision })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Entscheidung fehlgeschlagen.');
app.showToast('Entscheidung gespeichert.');
renderProjectFiles();
})
.catch(err => {
console.error('Freigabe-Entscheidung fehlgeschlagen:', err);
alert('Entscheidung fehlgeschlagen: ' + (err.message || err));
});
};
// deleteProjectFile: löscht (nach Bestätigung) eine Datei; der Server
// erlaubt das nur dem Uploader (solange 'pending') oder einem Admin.
window.deleteProjectFile = function(fileId){
if(!confirm('Datei wirklich löschen?')) return;
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
openComments.delete(fileId);
app.showToast('Datei gelöscht.');
renderProjectFiles();
})
.catch(err => {
console.error('Datei löschen fehlgeschlagen:', err);
alert('Löschen fehlgeschlagen: ' + (err.message || err));
});
};
// toggleFileComments: klappt den Kommentar-Thread einer Datei auf/zu und
// lädt ihn bei Bedarf.
window.toggleFileComments = function(fileId){
const el = document.getElementById('fileComments_' + fileId);
if(!el) return;
if(openComments.has(fileId)){
openComments.delete(fileId);
el.style.display = 'none';
} else {
openComments.add(fileId);
el.style.display = '';
loadFileComments(fileId);
}
};
// loadFileComments: lädt die Kommentare einer Datei (GET) und rendert
// Liste + Eingabefeld in den zugehörigen Thread-Container.
function loadFileComments(fileId){
const el = document.getElementById('fileComments_' + fileId);
if(!el) return;
el.innerHTML = '<span class="hint">Lade Kommentare…</span>';
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const comments = Array.isArray(data.comments) ? data.comments : [];
const list = comments.length === 0
? '<span class="hint">Noch keine Kommentare.</span>'
: comments.map(c => `
<div class="file-comment">
<b>${esc(c.displayName || '–')}</b>
<span class="file-comment-date">${esc(formatDateTimeDE(c.createdAt))}</span>
<div>${esc(c.body)}</div>
</div>`).join('');
el.innerHTML = `
<div class="file-comment-list">${list}</div>
<div class="file-comment-form">
<input type="text" id="fileCommentInput_${esc(fileId)}" placeholder="Kommentar schreiben…">
<button class="small" onclick="submitFileComment('${esc(fileId)}')">Senden</button>
</div>`;
})
.catch(err => {
el.innerHTML = '<span class="hint">Kommentare konnten nicht geladen werden.</span>';
console.error('Kommentare laden fehlgeschlagen:', err);
});
}
// submitFileComment: schickt den Text des Eingabefelds einer Datei als
// neuen Kommentar (jeder eingeloggte Benutzer darf kommentieren) und lädt
// den Thread danach neu.
window.submitFileComment = function(fileId){
const input = document.getElementById('fileCommentInput_' + fileId);
const body = input ? input.value.trim() : '';
if(!body) return;
fetch(baseUrl() + '/project-files/' + encodeURIComponent(fileId) + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ body })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Kommentar fehlgeschlagen.');
// renderProjectFiles() baut die Liste neu auf (aktualisiert auch
// commentCount) und lädt aufgeklappte Threads (openComments) neu.
renderProjectFiles();
})
.catch(err => {
console.error('Kommentar senden fehlgeschlagen:', err);
alert('Kommentar fehlgeschlagen: ' + (err.message || err));
});
};
})();