Files
projektuebersicht/public/js/plan/plan-files.js
T
erikandClaude Sonnet 5 db63a29365 Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:14:47 +02:00

354 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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
// 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 = '<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));
});
};
})();