All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
+251
-21
@@ -5,10 +5,15 @@
|
||||
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
|
||||
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
|
||||
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
|
||||
// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen-
|
||||
// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird
|
||||
// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js
|
||||
// erneut aufgerufen.
|
||||
// Datumsbereichs (fitPlanGanttRange) sowie die Ressourcen-Auslastung
|
||||
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
|
||||
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
|
||||
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
|
||||
// offene Aufgaben, nächster Meilenstein, Team-Auslastung, alle aus denselben
|
||||
// Plan-Daten abgeleitet). renderPlanGantt ist die zentrale Neu-Render-
|
||||
// Funktion (rendert am Ende auch Auslastung und KPIs neu) und wird nach
|
||||
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
|
||||
// aufgerufen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -28,7 +33,7 @@
|
||||
let start = parseDateISO(document.getElementById('planGanttStart').value);
|
||||
let end = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
if(!start || !end){
|
||||
const range = ganttRangeFromProjects([app.project], 7);
|
||||
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
|
||||
start = range.start; end = range.end;
|
||||
document.getElementById('planGanttStart').value = formatDateISO(start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(end);
|
||||
@@ -36,10 +41,148 @@
|
||||
const showVacation = document.getElementById('planShowVacation').checked;
|
||||
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
|
||||
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath });
|
||||
const ghost = collectGhostDates();
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, {
|
||||
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
|
||||
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
|
||||
});
|
||||
container.scrollLeft = prevScroll;
|
||||
requestAnimationFrame(() => renderGanttDependencies(container));
|
||||
renderPlanWarnings();
|
||||
renderResourceLoad();
|
||||
renderPlanKpis();
|
||||
// Aufgabenliste und Dashboard zeigen dieselben Aufgaben-/Meilensteindaten
|
||||
// in anderer Form (js/plan/plan-tasks.js, js/plan/plan-sections.js). Sie
|
||||
// hängen sich hier mit ein, weil renderPlanGantt() die zentrale
|
||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||
};
|
||||
|
||||
// collectGhostDates: liest die Termine derselben Aufgaben/Meilensteine aus
|
||||
// der letzten früheren Sitzung MIT Planinhalt (app.previousPlannedProjectVersion()
|
||||
// aus plan-history.js) und gibt sie als Maps id -> Termin zurück, die
|
||||
// buildGanttHtml als ghostTodos/ghostMilestones für die gestrichelten
|
||||
// Vergleichs-Umrisse nutzt. Das Label der gefundenen Sitzung wird
|
||||
// mitgeliefert, damit die Tooltips sie namentlich nennen können statt
|
||||
// pauschal "Vorwoche" zu behaupten.
|
||||
// Leere Maps, wenn der Schalter aus ist, es keine frühere Fassung mit
|
||||
// Terminplan gibt oder dem Projekt die projectUid fehlt (Altdaten).
|
||||
// Rückgabe: { todos: Map, milestones: Map, label: String }.
|
||||
function collectGhostDates(){
|
||||
const empty = { todos: new Map(), milestones: new Map(), label: '' };
|
||||
const toggle = document.getElementById('planShowGhost');
|
||||
if(toggle && !toggle.checked) return empty;
|
||||
if(typeof app.previousPlannedProjectVersion !== 'function') return empty;
|
||||
const prev = app.previousPlannedProjectVersion();
|
||||
if(!prev || !prev.project || !prev.project.plan) return empty;
|
||||
|
||||
const todos = new Map();
|
||||
(prev.project.plan.todos || []).forEach(t => {
|
||||
if(t.id && t.start && t.end) todos.set(t.id, { start: t.start, end: t.end });
|
||||
});
|
||||
const milestones = new Map();
|
||||
(prev.project.plan.milestones || []).forEach(m => {
|
||||
if(m.id && m.date) milestones.set(m.id, m.date);
|
||||
});
|
||||
return { todos, milestones, label: prev.week.label };
|
||||
}
|
||||
|
||||
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
|
||||
// (#planWarnings in Test.html) mit den Urlaubs-Kollisionen des
|
||||
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
|
||||
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
|
||||
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
|
||||
// garantiert dasselbe melden. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanWarnings = function(){
|
||||
const el = document.getElementById('planWarnings');
|
||||
if(!el || !app.project) return;
|
||||
if(typeof app.absenceConflictsForTodo !== 'function'){ el.innerHTML = ''; return; }
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
const rows = [];
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(t.done) return; // erledigte Aufgaben sind kein Planungsproblem mehr
|
||||
const conflicts = app.absenceConflictsForTodo(t);
|
||||
conflicts.forEach(c => {
|
||||
rows.push(`<li><b>${esc(t.title || 'TODO')}</b> — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})</li>`);
|
||||
});
|
||||
});
|
||||
|
||||
if(rows.length === 0){ el.innerHTML = ''; return; }
|
||||
el.innerHTML = `<div class="plan-warning-band">
|
||||
<div class="plan-warning-head">${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten</div>
|
||||
<ul class="plan-warning-list">${rows.join('')}</ul>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
|
||||
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
|
||||
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
|
||||
// Aufgaben). Rückgabe: HTML-String.
|
||||
function kpiTile(label, value, sub, warn){
|
||||
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
|
||||
<div class="kpi-label">${esc(label)}</div>
|
||||
<div class="kpi-value">${esc(value)}</div>
|
||||
<div class="kpi-sub">${esc(sub)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
|
||||
// #planKpiRow in Test.html) mit vier Kennzahlen, die alle aus den
|
||||
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
|
||||
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
|
||||
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
|
||||
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
|
||||
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
|
||||
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
|
||||
window.renderPlanKpis = function(){
|
||||
const row = document.getElementById('planKpiRow');
|
||||
if(!row || !app.project) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let progressTile;
|
||||
if(todos.length === 0){
|
||||
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
|
||||
} else {
|
||||
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
|
||||
const doneCount = todos.filter(t => t.done).length;
|
||||
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
|
||||
}
|
||||
|
||||
const open = todos.filter(t => !t.done);
|
||||
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
|
||||
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
|
||||
|
||||
const upcoming = milestones
|
||||
.filter(m => m.date && parseDateISO(m.date) >= today)
|
||||
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
let milestoneTile;
|
||||
if(upcoming.length > 0){
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
|
||||
} else {
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
|
||||
}
|
||||
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
let loadTile;
|
||||
if(loadValues.length === 0){
|
||||
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
|
||||
} else {
|
||||
const avgPct = Math.round(loadValues.reduce((s, l) => s + (l.days / maxDays * 100), 0) / loadValues.length);
|
||||
const top = loadValues.slice().sort((a, b) => b.days - a.days)[0];
|
||||
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.days > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch keine Aufgaben zugewiesen');
|
||||
}
|
||||
|
||||
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
|
||||
};
|
||||
|
||||
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
|
||||
@@ -129,7 +272,9 @@
|
||||
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
|
||||
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
|
||||
window.fitPlanGanttRange = function(){
|
||||
const range = ganttRangeFromProjects([app.project], 7);
|
||||
// Nur die Abwesenheiten der Projektbeteiligten dürfen den Zeitraum
|
||||
// aufziehen — alle anderen Urlaubszeilen werden ohnehin nicht gezeichnet.
|
||||
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
|
||||
document.getElementById('planGanttStart').value = formatDateISO(range.start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
|
||||
renderPlanGantt();
|
||||
@@ -159,26 +304,34 @@
|
||||
|
||||
// ---------- Ressourcen-Auslastung ----------
|
||||
|
||||
// renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und
|
||||
// rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits-
|
||||
// Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus
|
||||
// plan-modals.js.
|
||||
window.renderResourceLoad = function(){
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
if(!container || !app.project) return;
|
||||
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs.
|
||||
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
|
||||
// Auslastungs-Modal) und renderPlanKpis() (Team-Auslastung-Kachel), damit
|
||||
// beide dieselbe Zahl zeigen.
|
||||
//
|
||||
// Neben der Auslastung IN DIESEM Projekt (days/todos) wird zusätzlich
|
||||
// erfasst, wie stark dieselbe Person in den ÜBRIGEN Projekten derselben
|
||||
// Sitzung gebunden ist (otherDays/otherProjects). Ohne diesen Blick über den
|
||||
// Projektrand ist eine Überbuchung strukturell unsichtbar: jemand kann hier
|
||||
// entspannt aussehen und trotzdem in vier weiteren Projekten verplant sein.
|
||||
// Die Daten dafür sind bereits vorhanden — app.week.projects enthält alle
|
||||
// Projekte der Sitzung (GET /load-all lädt sie ohnehin komplett).
|
||||
//
|
||||
// Rückgabe: { loads, maxDays } — loads ist ein Objekt Person-ID ->
|
||||
// { person, roles, days, todos, otherDays, otherProjects }, maxDays das
|
||||
// Maximum über alle Personen (mindestens 1, für Prozent-Berechnungen; bezieht
|
||||
// sich bewusst nur auf die projekteigenen Tage, damit die Balken im Modal
|
||||
// untereinander vergleichbar bleiben).
|
||||
function computeResourceLoads(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
if(assignees.length === 0){
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const loads = {};
|
||||
assignees.forEach(a => {
|
||||
if(!loads[a.person.id]){
|
||||
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] };
|
||||
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [], otherDays: 0, otherProjects: [] };
|
||||
}
|
||||
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
|
||||
});
|
||||
@@ -193,14 +346,76 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Auslastung in den anderen Projekten derselben Sitzung
|
||||
((app.week && app.week.projects) || []).forEach(other => {
|
||||
if(other.id === app.project.id) return;
|
||||
const otherTodos = (other.plan && Array.isArray(other.plan.todos)) ? other.plan.todos : [];
|
||||
otherTodos.forEach(t => {
|
||||
if(!t.start || !t.end || !Array.isArray(t.assignees)) return;
|
||||
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
|
||||
t.assignees.forEach(pid => {
|
||||
if(!loads[pid]) return; // nur Personen, die auch hier beteiligt sind
|
||||
loads[pid].otherDays += days;
|
||||
const label = other.nr ? (other.nr + ' ' + (other.name || '')) : (other.name || 'Projekt');
|
||||
if(!loads[pid].otherProjects.includes(label)) loads[pid].otherProjects.push(label);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
|
||||
return { loads, maxDays };
|
||||
}
|
||||
|
||||
// openResourceModal: springt in den Bereich "Terminplan", in dem die
|
||||
// Auslastungskarten stehen (#resourceLoadContainer). Früher öffnete die
|
||||
// Funktion ein eigenes Modal; seit die Seite in Bereiche gegliedert ist
|
||||
// (js/plan/plan-sections.js), ist der Inhalt fester Bestandteil des
|
||||
// Terminplan-Bereichs. Der Name bleibt, weil die Kompakt-Karte
|
||||
// "Ressourcen-Auslastung" im Dashboard darauf verweist.
|
||||
window.openResourceModal = function(){
|
||||
if(typeof showPlanSection === 'function') showPlanSection('terminplan');
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
if(container) container.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
// renderResourceLoad: rendert pro Person eine Auslastungskarte mit Balken
|
||||
// und Abwesenheits-Hinweis in #resourceLoadContainer (Inhalt des
|
||||
// Auslastungs-Modals) sowie eine kompakte Vorschau (Balkenreihe) in
|
||||
// #planResourceSummary (die Kompakt-Karte selbst). Ein Klick auf eine
|
||||
// Detailkarte öffnet das Abwesenheits-Modal aus plan-modals.js.
|
||||
window.renderResourceLoad = function(){
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
const summaryEl = document.getElementById('planResourceSummary');
|
||||
if(!container || !app.project) return;
|
||||
const { loads, maxDays } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
|
||||
if(loadValues.length === 0){
|
||||
container.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
|
||||
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if(summaryEl){
|
||||
const cells = loadValues.map(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const otherHint = l.otherDays > 0 ? ` (+${l.otherDays} anderswo)` : '';
|
||||
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, pct / 100)}" title="${esc(l.person.name)}: ${l.days} Tage${otherHint}"></div>`;
|
||||
}).join('');
|
||||
const alsoBooked = loadValues.filter(l => l.otherDays > 0).length;
|
||||
const chip = alsoBooked > 0
|
||||
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
|
||||
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
|
||||
summaryEl.innerHTML = `<div class="plan-heat-row">${cells}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
}
|
||||
|
||||
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
|
||||
html += '<div class="load-grid plan-resource-grid">';
|
||||
Object.values(loads).forEach(l => {
|
||||
const pct = Math.round(l.days / maxDays * 100);
|
||||
const absCount = app.countPersonAbsences(l.person.id);
|
||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
|
||||
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">${absCount} Abwesenheit(en)</div>` : '';
|
||||
|
||||
let todoListHtml;
|
||||
if(l.todos.length === 0){
|
||||
@@ -212,10 +427,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Gegenüberstellung: Tage in diesem Projekt vs. in den übrigen Projekten
|
||||
// derselben Sitzung (siehe computeResourceLoads) — macht sichtbar, wenn
|
||||
// jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
|
||||
const otherBadge = l.otherDays > 0
|
||||
? `<div class="plan-resource-other" title="${esc(l.otherProjects.join('\n'))}">
|
||||
+${l.otherDays} Tage in ${l.otherProjects.length} weiteren Projekt${l.otherProjects.length === 1 ? '' : 'en'}
|
||||
</div>`
|
||||
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
|
||||
|
||||
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
||||
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
|
||||
<div class="load-num" style="font-size:18px">${l.days}</div>
|
||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
|
||||
${otherBadge}
|
||||
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
|
||||
${absBadge}
|
||||
</div>`;
|
||||
@@ -226,7 +451,12 @@
|
||||
|
||||
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
|
||||
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
|
||||
// Seit der Projektbereich ein Bereich der Hauptseite ist, läuft dieser
|
||||
// Lauscher auch, während ein ganz anderer Bereich sichtbar ist. In einem
|
||||
// ausgeblendeten Container sind alle gemessenen Positionen 0 — das Zeichnen
|
||||
// wäre nicht nur überflüssig, sondern falsch. Deshalb der Bereichs-Test.
|
||||
window.addEventListener('resize', () => {
|
||||
if(currentTab !== 'plan' || !app.project) return;
|
||||
if(app.resizeTimer) clearTimeout(app.resizeTimer);
|
||||
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user