Projektauswertung hinzugefügt
This commit is contained in:
@@ -443,7 +443,7 @@ Die automatische Berechnung von Arbeitszeiten und Überstunden dient lediglich d
|
||||
|
||||
Diese DSGVO-Dokumentation wird bei Änderungen der Datenverarbeitung aktualisiert. Die aktuelle Version ist immer im System verfügbar.
|
||||
|
||||
**Letzte Aktualisierung:** [Datum]
|
||||
**Letzte Aktualisierung:** [10.03.2026]
|
||||
|
||||
**Version:** 1.0
|
||||
|
||||
|
||||
@@ -376,6 +376,40 @@ body {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Dashboard: Überstunden-Wert standardmäßig weichzeichnen */
|
||||
.stat-value-blurred {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
/* Wrapper nur um den Wert, damit Overlay nicht die ganze Karte bedeckt */
|
||||
.stat-card-overtime .stat-value-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Hinweistext direkt über dem geblurrten Überstunden-Wert */
|
||||
.stat-card-overtime .stat-blur-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, rgba(248, 249, 250, 0.95), rgba(248, 249, 250, 0.8));
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card-overtime:hover .stat-blur-hint {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
|
||||
@@ -113,6 +113,21 @@ async function loadUserStats() {
|
||||
|
||||
// Beim Laden der Seite
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
// Blur-Effekt für aktuelle Überstunden: Standard = geblurrt, bei Hover klar
|
||||
(function initOvertimeBlur() {
|
||||
const overtimeCard = document.querySelector('.stat-card-overtime');
|
||||
if (!overtimeCard) return;
|
||||
const valueEl = overtimeCard.querySelector('#currentOvertime');
|
||||
if (!valueEl) return;
|
||||
|
||||
overtimeCard.addEventListener('mouseenter', () => {
|
||||
valueEl.classList.remove('stat-value-blurred');
|
||||
});
|
||||
|
||||
overtimeCard.addEventListener('mouseleave', () => {
|
||||
valueEl.classList.add('stat-value-blurred');
|
||||
});
|
||||
})();
|
||||
// Letzte Woche vom Server laden
|
||||
try {
|
||||
const response = await fetch('/api/user/last-week');
|
||||
|
||||
@@ -10,6 +10,18 @@ const { getCurrentOvertimeForUser } = require('../services/overtime-service');
|
||||
|
||||
// Routes registrieren
|
||||
function registerVerwaltungRoutes(app) {
|
||||
// Helper: Minuten in Format h:mm (z. B. 90 -> "1:30", -45 -> "-0:45")
|
||||
function minutesToHhMm(totalMinutes) {
|
||||
if (totalMinutes == null || !Number.isFinite(Number(totalMinutes))) return '0:00';
|
||||
const n = Number(totalMinutes);
|
||||
const sign = n < 0 ? -1 : 1;
|
||||
const absVal = Math.abs(n);
|
||||
let h = Math.floor(absVal / 60);
|
||||
let min = Math.round(absVal - h * 60);
|
||||
const prefix = sign < 0 ? '-' : '';
|
||||
return prefix + h + ':' + String(min).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Verwaltungs-Bereich
|
||||
app.get('/verwaltung', requireVerwaltung, (req, res) => {
|
||||
db.all(`
|
||||
@@ -132,6 +144,204 @@ function registerVerwaltungRoutes(app) {
|
||||
});
|
||||
});
|
||||
|
||||
// Projektauswertung nach Mitarbeitern für eine Projektnummer
|
||||
app.get('/verwaltung/projektauswertung', requireVerwaltung, (req, res) => {
|
||||
const projectNumberRaw = req.query.project ? String(req.query.project).trim() : '';
|
||||
const projectNumber = projectNumberRaw || null;
|
||||
|
||||
if (!projectNumber) {
|
||||
// Nur Formular anzeigen, noch keine Auswertung
|
||||
return res.render('projekt-auswertung', {
|
||||
user: {
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'verwaltung'
|
||||
},
|
||||
projectNumber: '',
|
||||
results: [],
|
||||
totalProjectHours: 0,
|
||||
hasResults: false
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregation der Projektstunden pro Mitarbeiter über alle 5 Aktivitäten
|
||||
const sql = `
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.firstname,
|
||||
u.lastname,
|
||||
(
|
||||
SUM(CASE WHEN te.activity1_project_number = ? THEN COALESCE(te.activity1_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity2_project_number = ? THEN COALESCE(te.activity2_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity3_project_number = ? THEN COALESCE(te.activity3_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity4_project_number = ? THEN COALESCE(te.activity4_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity5_project_number = ? THEN COALESCE(te.activity5_hours, 0) ELSE 0 END)
|
||||
) AS total_hours,
|
||||
ROUND(
|
||||
(
|
||||
SUM(CASE WHEN te.activity1_project_number = ? THEN COALESCE(te.activity1_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity2_project_number = ? THEN COALESCE(te.activity2_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity3_project_number = ? THEN COALESCE(te.activity3_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity4_project_number = ? THEN COALESCE(te.activity4_hours, 0) ELSE 0 END) +
|
||||
SUM(CASE WHEN te.activity5_project_number = ? THEN COALESCE(te.activity5_hours, 0) ELSE 0 END)
|
||||
) * 60
|
||||
) AS total_minutes
|
||||
FROM timesheet_entries te
|
||||
JOIN users u ON u.id = te.user_id
|
||||
GROUP BY u.id, u.firstname, u.lastname
|
||||
HAVING total_minutes <> 0
|
||||
ORDER BY u.lastname, u.firstname
|
||||
`;
|
||||
|
||||
const params = [
|
||||
projectNumber, projectNumber, projectNumber, projectNumber, projectNumber,
|
||||
projectNumber, projectNumber, projectNumber, projectNumber, projectNumber
|
||||
];
|
||||
|
||||
db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Fehler bei der Projektauswertung:', err);
|
||||
return res.status(500).send('Fehler bei der Projektauswertung');
|
||||
}
|
||||
|
||||
const rawResults = (rows || []).map((row) => {
|
||||
const totalMinutes = row.total_minutes || 0;
|
||||
const totalHours = row.total_hours || 0;
|
||||
return {
|
||||
userId: row.user_id,
|
||||
firstname: row.firstname,
|
||||
lastname: row.lastname,
|
||||
totalHours,
|
||||
totalMinutes,
|
||||
totalHoursFormatted: minutesToHhMm(totalMinutes)
|
||||
};
|
||||
});
|
||||
|
||||
const results = rawResults;
|
||||
|
||||
const totalProjectMinutes = results.reduce((sum, r) => sum + (r.totalMinutes || 0), 0);
|
||||
const totalProjectHours = totalProjectMinutes / 60;
|
||||
const totalProjectHoursFormatted = minutesToHhMm(totalProjectMinutes);
|
||||
|
||||
if (results.length === 0) {
|
||||
return res.render('projekt-auswertung', {
|
||||
user: {
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'verwaltung'
|
||||
},
|
||||
projectNumber: projectNumberRaw,
|
||||
results,
|
||||
totalProjectHours,
|
||||
totalProjectHoursFormatted,
|
||||
hasResults: false,
|
||||
breakdownByUser: {}
|
||||
});
|
||||
}
|
||||
|
||||
// Details pro Mitarbeiter (Aktivitäten) laden
|
||||
const breakdownByUser = {};
|
||||
const userIds = results.map((r) => r.userId);
|
||||
let pending = userIds.length;
|
||||
|
||||
const detailSql = `
|
||||
SELECT
|
||||
date,
|
||||
activity1_desc, activity1_hours, activity1_project_number,
|
||||
activity2_desc, activity2_hours, activity2_project_number,
|
||||
activity3_desc, activity3_hours, activity3_project_number,
|
||||
activity4_desc, activity4_hours, activity4_project_number,
|
||||
activity5_desc, activity5_hours, activity5_project_number
|
||||
FROM timesheet_entries
|
||||
WHERE user_id = ?
|
||||
AND (
|
||||
activity1_project_number = ? OR
|
||||
activity2_project_number = ? OR
|
||||
activity3_project_number = ? OR
|
||||
activity4_project_number = ? OR
|
||||
activity5_project_number = ?
|
||||
)
|
||||
ORDER BY date
|
||||
`;
|
||||
|
||||
userIds.forEach((userId) => {
|
||||
db.all(
|
||||
detailSql,
|
||||
[userId, projectNumber, projectNumber, projectNumber, projectNumber, projectNumber],
|
||||
(detailErr, rowsDetail) => {
|
||||
if (detailErr) {
|
||||
console.error('Fehler beim Laden der Projekt-Aktivitäten:', detailErr);
|
||||
breakdownByUser[userId] = [];
|
||||
} else {
|
||||
const activities = [];
|
||||
(rowsDetail || []).forEach((row) => {
|
||||
const date = row.date;
|
||||
|
||||
const pushActivity = (desc, hours) => {
|
||||
if (!hours || !Number.isFinite(Number(hours))) return;
|
||||
const decimal = Number(hours);
|
||||
const minutes = Math.round(decimal * 60);
|
||||
if (minutes === 0) return;
|
||||
activities.push({
|
||||
date,
|
||||
description: desc || '',
|
||||
hoursDecimal: decimal,
|
||||
minutes,
|
||||
formatted: minutesToHhMm(minutes)
|
||||
});
|
||||
};
|
||||
|
||||
if (row.activity1_project_number === projectNumber) {
|
||||
pushActivity(row.activity1_desc, row.activity1_hours);
|
||||
}
|
||||
if (row.activity2_project_number === projectNumber) {
|
||||
pushActivity(row.activity2_desc, row.activity2_hours);
|
||||
}
|
||||
if (row.activity3_project_number === projectNumber) {
|
||||
pushActivity(row.activity3_desc, row.activity3_hours);
|
||||
}
|
||||
if (row.activity4_project_number === projectNumber) {
|
||||
pushActivity(row.activity4_desc, row.activity4_hours);
|
||||
}
|
||||
if (row.activity5_project_number === projectNumber) {
|
||||
pushActivity(row.activity5_desc, row.activity5_hours);
|
||||
}
|
||||
});
|
||||
|
||||
// Nach Datum sortieren
|
||||
activities.sort((a, b) => {
|
||||
if (a.date === b.date) return 0;
|
||||
return a.date < b.date ? -1 : 1;
|
||||
});
|
||||
|
||||
breakdownByUser[userId] = activities;
|
||||
}
|
||||
|
||||
pending -= 1;
|
||||
if (pending === 0) {
|
||||
res.render('projekt-auswertung', {
|
||||
user: {
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'verwaltung'
|
||||
},
|
||||
projectNumber: projectNumberRaw,
|
||||
results,
|
||||
totalProjectHours,
|
||||
totalProjectHoursFormatted,
|
||||
hasResults: results.length > 0,
|
||||
breakdownByUser
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API: Überstunden-Offset für einen User setzen (positiv/negativ)
|
||||
app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
|
||||
@@ -80,12 +80,15 @@
|
||||
<!-- Rechte Seitenleiste mit Statistiken und Erfassungs-URLs -->
|
||||
<div class="user-stats-panel">
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-card stat-card-overtime">
|
||||
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">
|
||||
Aktuelle Überstunden
|
||||
<span class="help-icon" onclick="showHelpModal('overtime-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</div>
|
||||
<div class="stat-value" id="currentOvertime">-</div>
|
||||
<div class="stat-value-wrapper">
|
||||
<div class="stat-value stat-value-blurred" id="currentOvertime">-</div>
|
||||
<div class="stat-blur-hint">Zum Anzeigen Maus drüberziehen</div>
|
||||
</div>
|
||||
<div class="stat-unit">Stunden</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<a href="/overtime-breakdown" class="btn btn-primary" style="width: 100%; font-size: 13px; padding: 8px 12px;">Details anzeigen</a>
|
||||
|
||||
185
views/projekt-auswertung.ejs
Normal file
185
views/projekt-auswertung.ejs
Normal file
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Projektauswertung - Verwaltung</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<%- include('header') %>
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<img src="/images/header.png" alt="Logo" class="navbar-logo">
|
||||
<h1>Projektauswertung - Verwaltung</h1>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span>Verwaltung: <%= user.firstname %> <%= user.lastname %></span>
|
||||
<% if (user.roles && user.roles.length > 1) { %>
|
||||
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
|
||||
<% user.roles.forEach(function(role) { %>
|
||||
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
<a href="/logout" class="btn btn-logout">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container verwaltung-container">
|
||||
<div class="verwaltung-panel">
|
||||
<h2>Projektauswertung nach Mitarbeitern</h2>
|
||||
<p>Geben Sie eine Projektnummer ein, um alle erfassten Stunden pro Mitarbeiter für dieses Projekt auszuwerten.</p>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<a href="/verwaltung" class="btn btn-secondary">« Zurück zur Verwaltung</a>
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/verwaltung/projektauswertung" class="projekt-filter-form" style="margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="projectNumber"><strong>Projektnummer</strong></label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectNumber"
|
||||
name="project"
|
||||
value="<%= projectNumber || '' %>"
|
||||
class="form-control"
|
||||
placeholder="z. B. 12345"
|
||||
required
|
||||
style="max-width: 240px;">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Auswerten</button>
|
||||
</form>
|
||||
|
||||
<% if (projectNumber && !hasResults) { %>
|
||||
<div class="empty-state">
|
||||
<p>Für das Projekt <strong><%= projectNumber %></strong> wurden keine Stunden gefunden.</p>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (hasResults) { %>
|
||||
<h3>Ergebnis für Projekt <span style="white-space: nowrap;"><%= projectNumber %></span></h3>
|
||||
<table class="timesheet-table" style="margin-top: 15px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitarbeiter</th>
|
||||
<th style="text-align: right;">Gesamtzeit (h:mm)</th>
|
||||
<th style="width: 1%; white-space: nowrap;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% results.forEach(function(row) {
|
||||
const activities = (breakdownByUser && breakdownByUser[row.userId]) || [];
|
||||
%>
|
||||
<tr class="employee-summary-row" data-user-id="<%= row.userId %>">
|
||||
<td><strong><%= row.firstname %> <%= row.lastname %></strong></td>
|
||||
<td style="text-align: right;"><%= row.totalHoursFormatted %> h</td>
|
||||
<td style="text-align: right;">
|
||||
<% if (activities.length > 0) { %>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm toggle-details-btn"
|
||||
data-user-id="<%= row.userId %>">
|
||||
Details
|
||||
</button>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% if (activities.length > 0) { %>
|
||||
<tr class="employee-details-row" data-user-id="<%= row.userId %>" style="display: none; background-color: #fafafa;">
|
||||
<td colspan="3">
|
||||
<div style="padding: 10px 5px 5px 5px;">
|
||||
<strong>Aufschlüsselung für <%= row.firstname %> <%= row.lastname %>:</strong>
|
||||
<table class="timesheet-table" style="margin-top: 8px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Tätigkeit</th>
|
||||
<th style="text-align: right;">Stunden (h:mm)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% activities.forEach(function(a) { %>
|
||||
<tr>
|
||||
<td><%= a.date %></td>
|
||||
<td><%= a.description && a.description.trim() !== '' ? a.description : '-' %></td>
|
||||
<td style="text-align: right;"><%= a.formatted %> h</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>Gesamt Projektstunden</th>
|
||||
<th style="text-align: right;"><%= totalProjectHoursFormatted %> h</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Rollenwechsel-Handler (analog zu Verwaltung-Ansicht) und Details-Collapsibles
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||
if (roleSwitcher) {
|
||||
roleSwitcher.addEventListener('change', async function() {
|
||||
const newRole = this.value;
|
||||
try {
|
||||
const response = await fetch('/api/user/switch-role', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
if (newRole === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else if (newRole === 'verwaltung') {
|
||||
window.location.href = '/verwaltung';
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Wechseln der Rolle: ' + (result.error || 'Unbekannter Fehler'));
|
||||
this.value = '<%= user.currentRole || "verwaltung" %>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Rollenwechsel:', error);
|
||||
alert('Fehler beim Wechseln der Rolle');
|
||||
this.value = '<%= user.currentRole || "verwaltung" %>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mitarbeiter-Details (Aktivitäten) ein-/ausklappen
|
||||
document.querySelectorAll('.toggle-details-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const userId = this.dataset.userId;
|
||||
const row = document.querySelector('.employee-details-row[data-user-id="' + userId + '"]');
|
||||
if (!row) return;
|
||||
const isHidden = row.style.display === 'none' || row.style.display === '';
|
||||
row.style.display = isHidden ? 'table-row' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<%- include('footer') %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span>Verwaltung: <%= user.firstname %> <%= user.lastname %></span>
|
||||
<a href="/verwaltung/projektauswertung" class="btn btn-secondary" style="margin-right: 10px;">Projektauswertung</a>
|
||||
<% if (user.roles && user.roles.length > 1) { %>
|
||||
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
|
||||
|
||||
Reference in New Issue
Block a user