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>
354 lines
17 KiB
JavaScript
354 lines
17 KiB
JavaScript
// -----------------------------
|
||
// 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));
|
||
});
|
||
};
|
||
})();
|