Sortieungsoptionen in Verwaltung, Umstellung der PDF generation auf generierung bei abgabe und ablage auf dem Sateisystem um einen Festen Stand zu garantieren
This commit is contained in:
10
database.js
10
database.js
@@ -109,6 +109,16 @@ function initDatabase() {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Migration: pdf_path Spalte hinzufügen (Filesystem-Cache für eingefrorene PDFs)
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN pdf_path TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
if (!err.message.includes('duplicate column name')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte pdf_path:', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: version_reason Spalte hinzufügen
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN version_reason TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
|
||||
@@ -135,7 +135,7 @@ Nach der Installation sind folgende Benutzer verfügbar:
|
||||
2. Sie sehen alle eingereichten Stundenzettel im Postfach
|
||||
3. **PDF erstellen:**
|
||||
- Klicken Sie auf "PDF herunterladen" neben dem gewünschten Stundenzettel
|
||||
- Die PDF wird automatisch generiert und heruntergeladen
|
||||
- Die PDF wird serverseitig erzeugt, gespeichert (eingefrorener Stand) und heruntergeladen
|
||||
4. **Überstunden korrigieren:**
|
||||
- In der Wochenansicht können Sie manuelle Korrekturen (Offset) für jeden Mitarbeiter vornehmen
|
||||
5. **Kommentare hinzufügen:**
|
||||
@@ -148,6 +148,14 @@ Nach der Installation sind folgende Benutzer verfügbar:
|
||||
- Urlaubstage
|
||||
- Gesamtstundensumme
|
||||
|
||||
#### PDF-Speicherung (eingefrorener Stand)
|
||||
|
||||
- **Versionstreue**: Die PDF basiert auf dem Stand zum Zeitpunkt der Einreichung (`submitted_at`) und wird danach **nicht mehr** aus aktuellen DB-Daten „neu berechnet“.
|
||||
- **Storage**: Generierte Stundenzettel-PDFs werden auf dem Server im Filesystem abgelegt (Cache/Archiv).
|
||||
- **Konfiguration**: Der Speicherort kann über die Umgebungsvariable `PDF_BASE_DIR` gesetzt werden.
|
||||
- Wenn `PDF_BASE_DIR` nicht gesetzt ist, wird standardmäßig `./pdf-cache/` (relativ zum Projekt) verwendet.
|
||||
- **Zuordnung**: Der Pfad wird in `weekly_timesheets.pdf_path` gespeichert.
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Backend:** Node.js + Express
|
||||
|
||||
@@ -12,4 +12,5 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DB_PATH=/app/data/stundenerfassung.db
|
||||
- TZ=Europe/Berlin
|
||||
- PDF_BASE_DIR=/app/data/pdfs
|
||||
restart: unless-stopped
|
||||
|
||||
55
helpers/pdf-paths.js
Normal file
55
helpers/pdf-paths.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const path = require('path');
|
||||
const { getCalendarWeek } = require('./utils');
|
||||
|
||||
function getPdfBaseDir() {
|
||||
const configured = process.env.PDF_BASE_DIR && String(process.env.PDF_BASE_DIR).trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
return path.resolve(path.join(__dirname, '..', 'pdf-cache'));
|
||||
}
|
||||
|
||||
function asciiSafe(value) {
|
||||
const s = String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
return s.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function getYearFromDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
const year = d.getFullYear();
|
||||
return Number.isFinite(year) ? String(year) : 'unknown-year';
|
||||
}
|
||||
|
||||
function buildTimesheetPdfLocation(timesheetRow) {
|
||||
if (!timesheetRow) throw new Error('timesheetRow is required');
|
||||
const baseDir = getPdfBaseDir();
|
||||
|
||||
const userId = timesheetRow.user_id;
|
||||
const year = getYearFromDate(timesheetRow.week_start);
|
||||
const kw = getCalendarWeek(timesheetRow.week_start);
|
||||
const version = timesheetRow.version || 1;
|
||||
|
||||
const first = asciiSafe(timesheetRow.firstname || '');
|
||||
const last = asciiSafe(timesheetRow.lastname || '');
|
||||
const namePart = [last, first].filter(Boolean).join('_') || `user_${userId}`;
|
||||
|
||||
const filename = `${namePart}_timesheet_${timesheetRow.id}_v${version}_KW${String(kw).padStart(2, '0')}_${year}.pdf`;
|
||||
const relativePath = path.join(year, String(userId), filename);
|
||||
const absolutePath = path.join(baseDir, relativePath);
|
||||
|
||||
return {
|
||||
baseDir,
|
||||
year,
|
||||
kw,
|
||||
filename,
|
||||
relativePath,
|
||||
absolutePath,
|
||||
directory: path.dirname(absolutePath),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPdfBaseDir,
|
||||
buildTimesheetPdfLocation,
|
||||
};
|
||||
|
||||
@@ -1179,6 +1179,12 @@ table input[type="text"] {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Sortier-Buttons in der Verwaltungsansicht */
|
||||
.timesheet-sort-btn.active-sort {
|
||||
font-weight: 600;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
/* Wochen-Container (Level 2) */
|
||||
.weeks-container {
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -2,9 +2,16 @@
|
||||
|
||||
const { db } = require('../database');
|
||||
const { requireAuth, requireVerwaltung } = require('../middleware/auth');
|
||||
const { generatePDF } = require('../services/pdf-service');
|
||||
const { generatePDFToBuffer } = require('../services/pdf-service');
|
||||
const { getHolidaysForDateRange } = require('../services/feiertage-service');
|
||||
const { hasRole } = require('../helpers/utils');
|
||||
const fs = require('fs');
|
||||
const {
|
||||
fileExists,
|
||||
savePdfBufferAtomic,
|
||||
resolvePdfLocationFromTimesheetRow,
|
||||
resolveAbsolutePathFromRelative,
|
||||
} = require('../services/pdf-storage-service');
|
||||
|
||||
/** Plausibilitätsprüfung Projektnummer: 7 Ziffern, beginnt mit 5, dann YY (Jahr), dann 4 freie Ziffern (z.B. 5260001). */
|
||||
function isValidProjectNumber(value) {
|
||||
@@ -553,18 +560,90 @@ function registerTimesheetRoutes(app) {
|
||||
const isVerwaltung = hasRole(req, 'verwaltung') || hasRole(req, 'admin');
|
||||
|
||||
// Prüfe ob User Verwaltung/Admin ist oder ob das Timesheet dem User gehört
|
||||
db.get(`SELECT user_id FROM weekly_timesheets WHERE id = ?`, [timesheetId], (err, timesheet) => {
|
||||
if (err || !timesheet) {
|
||||
return res.status(404).send('Stundenzettel nicht gefunden');
|
||||
db.get(
|
||||
`SELECT wt.id, wt.user_id, wt.week_start, wt.week_end, wt.version, wt.submitted_at, wt.pdf_path,
|
||||
u.firstname, u.lastname
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
WHERE wt.id = ?`,
|
||||
[timesheetId],
|
||||
async (err, timesheet) => {
|
||||
if (err || !timesheet) {
|
||||
return res.status(404).send('Stundenzettel nicht gefunden');
|
||||
}
|
||||
|
||||
// Zugriff erlauben wenn Verwaltung/Admin ODER wenn Timesheet dem User gehört
|
||||
if (!(isVerwaltung || timesheet.user_id === userId)) {
|
||||
return res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
|
||||
const inline = req.query.inline === 'true';
|
||||
|
||||
// Zielpfad bestimmen: DB-Pfad bevorzugen, sonst berechnen
|
||||
const computedLocation = resolvePdfLocationFromTimesheetRow(timesheet);
|
||||
const relativePath = timesheet.pdf_path ? String(timesheet.pdf_path) : computedLocation.relativePath;
|
||||
const absolutePath = timesheet.pdf_path ? resolveAbsolutePathFromRelative(relativePath) : computedLocation.absolutePath;
|
||||
const filename = computedLocation.filename;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (inline) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Marker setzen, dass PDF heruntergeladen wurde (nur bei Download, nicht bei Vorschau)
|
||||
const downloadedBy = req.session.userId;
|
||||
if (downloadedBy) {
|
||||
db.run(
|
||||
`UPDATE weekly_timesheets
|
||||
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||
pdf_downloaded_by = ?
|
||||
WHERE id = ?`,
|
||||
[downloadedBy, timesheetId],
|
||||
(updateErr) => {
|
||||
if (updateErr) {
|
||||
console.error('Fehler beim Setzen des Download-Markers:', updateErr);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await fileExists(absolutePath);
|
||||
if (exists) {
|
||||
const stream = fs.createReadStream(absolutePath);
|
||||
stream.on('error', (streamErr) => {
|
||||
console.error('Fehler beim Lesen der PDF-Datei:', streamErr);
|
||||
if (!res.headersSent) res.status(500);
|
||||
res.end('Fehler beim Lesen der PDF-Datei');
|
||||
});
|
||||
return stream.pipe(res);
|
||||
}
|
||||
|
||||
// Nicht vorhanden: versionstreu generieren, speichern, dann senden
|
||||
const pdfBuffer = await generatePDFToBuffer(timesheetId, req);
|
||||
await savePdfBufferAtomic(absolutePath, pdfBuffer);
|
||||
|
||||
// Relativen Pfad in DB speichern (optional, Fehler nicht fatal)
|
||||
if (!timesheet.pdf_path) {
|
||||
db.run('UPDATE weekly_timesheets SET pdf_path = ? WHERE id = ?', [relativePath, timesheetId], (pathErr) => {
|
||||
if (pathErr) {
|
||||
console.warn('Warnung beim Speichern des PDF-Pfads:', pathErr.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.end(pdfBuffer);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Generieren/Speichern der PDF:', e);
|
||||
if (!res.headersSent) res.status(500);
|
||||
return res.end('Fehler beim Erstellen des PDFs');
|
||||
}
|
||||
}
|
||||
|
||||
// Zugriff erlauben wenn Verwaltung/Admin ODER wenn Timesheet dem User gehört
|
||||
if (isVerwaltung || timesheet.user_id === userId) {
|
||||
generatePDF(timesheetId, req, res);
|
||||
} else {
|
||||
res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
44
services/pdf-storage-service.js
Normal file
44
services/pdf-storage-service.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { buildTimesheetPdfLocation, getPdfBaseDir } = require('../helpers/pdf-paths');
|
||||
|
||||
async function ensureDirectoryExists(dirPath) {
|
||||
await fsp.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePdfBufferAtomic(targetPath, buffer) {
|
||||
const dir = path.dirname(targetPath);
|
||||
await ensureDirectoryExists(dir);
|
||||
|
||||
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
||||
await fsp.writeFile(tmpPath, buffer);
|
||||
await fsp.rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
function resolvePdfLocationFromTimesheetRow(timesheetRow) {
|
||||
return buildTimesheetPdfLocation(timesheetRow);
|
||||
}
|
||||
|
||||
function resolveAbsolutePathFromRelative(relativePath) {
|
||||
const baseDir = getPdfBaseDir();
|
||||
return path.join(baseDir, relativePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureDirectoryExists,
|
||||
fileExists,
|
||||
savePdfBufferAtomic,
|
||||
resolvePdfLocationFromTimesheetRow,
|
||||
resolveAbsolutePathFromRelative,
|
||||
};
|
||||
|
||||
@@ -73,6 +73,45 @@
|
||||
<div id="bulkDownloadStatus" style="margin-top: 12px; font-size: 13px; color: #666; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sortierung der Mitarbeiter-Gruppen -->
|
||||
<div style="margin-bottom: 25px; padding: 12px 16px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6;">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 13px; color: #555; font-weight: 500; margin-right: 5px;">Sortieren nach:</span>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="firstname" data-sort-direction="asc">
|
||||
Vorname ▲
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="firstname" data-sort-direction="desc">
|
||||
Vorname ▼
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="name" data-sort-direction="asc">
|
||||
Name ▲
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="name" data-sort-direction="desc">
|
||||
Name ▼
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="personalnummer" data-sort-direction="asc">
|
||||
Personalnr. ▲
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="personalnummer" data-sort-direction="desc">
|
||||
Personalnr. ▼
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="overtime" data-sort-direction="asc">
|
||||
Aktuelle Überstunden ▲
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="overtime" data-sort-direction="desc">
|
||||
Aktuelle Überstunden ▼
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="remainingVacation" data-sort-direction="asc">
|
||||
Verbleibender Urlaub ▲
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm timesheet-sort-btn" data-sort-field="remainingVacation" data-sort-direction="desc">
|
||||
Verbleibender Urlaub ▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>
|
||||
<div class="empty-state">
|
||||
<p>Keine eingereichten Stundenzettel vorhanden.</p>
|
||||
@@ -81,7 +120,14 @@
|
||||
<div class="timesheet-groups">
|
||||
<% groupedByEmployee.forEach(function(employee, employeeIndex) { %>
|
||||
<!-- Level 1: Mitarbeiter -->
|
||||
<div class="employee-group" data-employee-id="<%= employee.user.id %>" data-employee-index="<%= employeeIndex %>">
|
||||
<div
|
||||
class="employee-group"
|
||||
data-employee-id="<%= employee.user.id %>"
|
||||
data-employee-index="<%= employeeIndex %>"
|
||||
data-lastname="<%= employee.user.lastname || '' %>"
|
||||
data-firstname="<%= employee.user.firstname || '' %>"
|
||||
data-personalnummer="<%= employee.user.personalnummer || '' %>"
|
||||
>
|
||||
<div class="employee-header">
|
||||
<div class="employee-info">
|
||||
<div class="employee-name">
|
||||
@@ -513,9 +559,16 @@
|
||||
if (value === null) {
|
||||
el.textContent = '-';
|
||||
el.style.color = '';
|
||||
el.dataset.overtimeValue = '';
|
||||
} else {
|
||||
el.textContent = (value >= 0 ? '+' : '') + formatHoursMin(value);
|
||||
el.style.color = value >= 0 ? '#27ae60' : '#e74c3c';
|
||||
el.dataset.overtimeValue = String(value);
|
||||
}
|
||||
|
||||
const group = document.querySelector(`.employee-group[data-employee-id="${userId}"]`);
|
||||
if (group) {
|
||||
group.dataset.overtimeValue = el.dataset.overtimeValue || '';
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -523,6 +576,7 @@
|
||||
elements.forEach(el => {
|
||||
el.textContent = '-';
|
||||
el.style.color = '';
|
||||
el.dataset.overtimeValue = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -573,6 +627,12 @@
|
||||
} else {
|
||||
el.textContent = value.toFixed(1);
|
||||
}
|
||||
// Wert für Sortierung am Element und an der Mitarbeiter-Gruppe hinterlegen
|
||||
el.dataset.remainingVacation = value !== null ? String(value) : '';
|
||||
const group = el.closest('.employee-group') || document.querySelector(`.employee-group[data-employee-id="${el.dataset.userId}"]`);
|
||||
if (group) {
|
||||
group.dataset.remainingVacation = value !== null ? String(value) : '';
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden des verbleibenden Urlaubs für User', userId, err);
|
||||
@@ -585,6 +645,91 @@
|
||||
loadSickDays();
|
||||
loadCurrentOvertime();
|
||||
loadRemainingVacation();
|
||||
|
||||
function sortEmployeeGroups(field, direction) {
|
||||
const container = document.querySelector('.timesheet-groups');
|
||||
if (!container) return;
|
||||
const groups = Array.from(container.querySelectorAll('.employee-group'));
|
||||
if (groups.length === 0) return;
|
||||
const dir = direction === 'desc' ? -1 : 1;
|
||||
|
||||
function compareStrings(a, b) {
|
||||
const sa = (a || '').toString().toLowerCase();
|
||||
const sb = (b || '').toString().toLowerCase();
|
||||
return sa.localeCompare(sb, 'de-DE');
|
||||
}
|
||||
|
||||
function parseNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
let va;
|
||||
let vb;
|
||||
|
||||
if (field === 'name') {
|
||||
const aLast = a.dataset.lastname || '';
|
||||
const aFirst = a.dataset.firstname || '';
|
||||
const bLast = b.dataset.lastname || '';
|
||||
const bFirst = b.dataset.firstname || '';
|
||||
const cmp = compareStrings(`${aLast} ${aFirst}`, `${bLast} ${bFirst}`);
|
||||
return cmp * dir;
|
||||
}
|
||||
|
||||
if (field === 'firstname') {
|
||||
const aFirst = a.dataset.firstname || '';
|
||||
const bFirst = b.dataset.firstname || '';
|
||||
const firstCmp = compareStrings(aFirst, bFirst);
|
||||
if (firstCmp !== 0) return firstCmp * dir;
|
||||
// Bei gleichem Vornamen nach Nachname sortieren
|
||||
const aLast = a.dataset.lastname || '';
|
||||
const bLast = b.dataset.lastname || '';
|
||||
const lastCmp = compareStrings(aLast, bLast);
|
||||
return lastCmp * dir;
|
||||
}
|
||||
|
||||
if (field === 'personalnummer') {
|
||||
const aRaw = a.dataset.personalnummer || '';
|
||||
const bRaw = b.dataset.personalnummer || '';
|
||||
|
||||
if (!aRaw && !bRaw) return 0;
|
||||
if (!aRaw) return 1;
|
||||
if (!bRaw) return -1;
|
||||
|
||||
const digitsOnly = /^\d+$/;
|
||||
const aIsNum = digitsOnly.test(aRaw);
|
||||
const bIsNum = digitsOnly.test(bRaw);
|
||||
|
||||
if (aIsNum && bIsNum) {
|
||||
va = Number(aRaw);
|
||||
vb = Number(bRaw);
|
||||
if (va === vb) return 0;
|
||||
return va < vb ? -1 * dir : 1 * dir;
|
||||
}
|
||||
|
||||
const cmp = compareStrings(aRaw, bRaw);
|
||||
return cmp * dir;
|
||||
}
|
||||
|
||||
if (field === 'overtime') {
|
||||
va = parseNumberOrNull(a.dataset.overtimeValue);
|
||||
vb = parseNumberOrNull(b.dataset.overtimeValue);
|
||||
} else if (field === 'remainingVacation') {
|
||||
va = parseNumberOrNull(a.dataset.remainingVacation);
|
||||
vb = parseNumberOrNull(b.dataset.remainingVacation);
|
||||
}
|
||||
|
||||
if (va === null && vb === null) return 0;
|
||||
if (va === null) return 1;
|
||||
if (vb === null) return -1;
|
||||
if (va === vb) return 0;
|
||||
return va < vb ? -1 * dir : 1 * dir;
|
||||
});
|
||||
|
||||
groups.forEach(group => container.appendChild(group));
|
||||
}
|
||||
|
||||
// Überstunden-Korrektur-Historie laden/anzeigen
|
||||
function parseSqliteDatetime(value) {
|
||||
@@ -1057,6 +1202,30 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sortier-Buttons für Mitarbeiter-Gruppen
|
||||
(function initTimesheetGroupSorting() {
|
||||
const sortButtons = document.querySelectorAll('.timesheet-sort-btn');
|
||||
if (!sortButtons.length) return;
|
||||
|
||||
let activeSortButton = null;
|
||||
|
||||
sortButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const field = this.dataset.sortField;
|
||||
const direction = this.dataset.sortDirection || 'asc';
|
||||
if (!field) return;
|
||||
|
||||
sortEmployeeGroups(field, direction);
|
||||
|
||||
if (activeSortButton && activeSortButton !== this) {
|
||||
activeSortButton.classList.remove('active-sort');
|
||||
}
|
||||
this.classList.add('active-sort');
|
||||
activeSortButton = this;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Rollenwechsel-Handler
|
||||
|
||||
Reference in New Issue
Block a user