All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
@@ -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));
|
||||
});
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user