// ----------------------------- // 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 = 'Lade Benutzer…'; 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 = 'Keine Benutzer vorhanden.'; 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 = 'Keine weiteren Benutzer vorhanden, die prüfen könnten.'; return; } container.innerHTML = selectable.map(u => `` ).join(''); }) .catch(err => { container.innerHTML = 'Benutzer konnten nicht geladen werden.'; 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 = 'Dieses Projekt hat keine Projektnummer und kann daher keine Dateien erhalten.'; const summaryEl = document.getElementById('planFilesSummary'); if(summaryEl) summaryEl.innerHTML = 'Keine Projektnummer.'; return; } container.innerHTML = 'Lade Dateien…'; 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 = 'Dateien konnten nicht geladen werden.'; console.error('Dateiliste laden fehlgeschlagen:', err); }); }; // renderFilesSummary: füllt die Kompakt-Vorschau der "Dateien & Freigaben"- // Karte unter dem GANTT (Cockpit-Layout, #planFilesSummary in // App.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 = 'Noch keine Dateien.'; return; } const pendingCount = filesCache.filter(f => f.status === 'pending').length; const chip = pendingCount > 0 ? `${pendingCount} ausstehend` : 'Alle geprüft'; summaryEl.innerHTML = `
${esc(filesCache[0].originalFilename)}
${chip}
`; } // 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 = 'Noch keine Dateien für dieses Projekt hochgeladen.'; 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 => `${esc(r.displayName)}: ${esc(statusLabel(r.decision))}` ).join(''); const downloadUrl = baseUrl() + '/project-files/' + encodeURIComponent(f.id) + '/download'; return `
${esc(f.originalFilename)} ${esc(statusLabel(f.status))}
Hochgeladen von ${esc(f.uploadedBy || '–')} am ${esc(formatDateTimeDE(f.createdAt))}
${reviewerChips}
${canReview ? ` ` : ''} ${canDelete ? `` : ''}
`; }).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 = 'Lade Kommentare…'; 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 ? 'Noch keine Kommentare.' : comments.map(c => `
${esc(c.displayName || '–')} ${esc(formatDateTimeDE(c.createdAt))}
${esc(c.body)}
`).join(''); el.innerHTML = `
${list}
`; }) .catch(err => { el.innerHTML = 'Kommentare konnten nicht geladen werden.'; 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)); }); }; })();