Overtime Corrections mit Historie und Grund
This commit is contained in:
@@ -153,6 +153,15 @@
|
||||
<span class="summary-label">Manuelle Korrektur (Verwaltung):</span>
|
||||
<span class="summary-value" id="overtimeOffset">-</span>
|
||||
</div>
|
||||
<div id="correctionsSection" style="margin-top: 15px; display: none;">
|
||||
<div id="correctionsHeader" class="collapsible-header" style="cursor: pointer; padding: 12px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 600; color: #2c3e50;">Korrekturen durch die Verwaltung</span>
|
||||
<span id="correctionsToggleIcon" style="font-size: 16px; transition: transform 0.3s;">▼</span>
|
||||
</div>
|
||||
<div id="correctionsContent" style="display: none; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff; padding: 10px 12px;">
|
||||
<ul id="correctionsList" style="margin: 0; padding-left: 18px;"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Lade Daten...</div>
|
||||
@@ -213,6 +222,35 @@
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
// SQLite-Datetime (YYYY-MM-DD HH:MM:SS) robust parsen
|
||||
function parseSqliteDatetime(value) {
|
||||
if (!value) return null;
|
||||
const s = String(value);
|
||||
if (s.includes('T')) return new Date(s);
|
||||
// SQLite datetime('now') liefert UTC ohne "T" / "Z"
|
||||
return new Date(s.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
function formatHours(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const sign = n > 0 ? '+' : '';
|
||||
let s = sign + n.toFixed(2);
|
||||
s = s.replace(/\.00$/, '');
|
||||
s = s.replace(/(\.\d)0$/, '$1');
|
||||
return s;
|
||||
}
|
||||
|
||||
let correctionsExpanded = false;
|
||||
function toggleCorrectionsSection() {
|
||||
const content = document.getElementById('correctionsContent');
|
||||
const icon = document.getElementById('correctionsToggleIcon');
|
||||
if (!content || !icon) return;
|
||||
correctionsExpanded = !correctionsExpanded;
|
||||
content.style.display = correctionsExpanded ? 'block' : 'none';
|
||||
icon.style.transform = correctionsExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
|
||||
}
|
||||
|
||||
// Überstunden-Daten laden
|
||||
async function loadOvertimeBreakdown() {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
@@ -273,6 +311,41 @@
|
||||
} else {
|
||||
offsetItem.style.display = 'none';
|
||||
}
|
||||
|
||||
// Korrekturen durch die Verwaltung anzeigen (Collapsible, nur wenn vorhanden)
|
||||
const correctionsSectionEl = document.getElementById('correctionsSection');
|
||||
const correctionsListEl = document.getElementById('correctionsList');
|
||||
const correctionsHeaderEl = document.getElementById('correctionsHeader');
|
||||
const correctionsContentEl = document.getElementById('correctionsContent');
|
||||
const correctionsIconEl = document.getElementById('correctionsToggleIcon');
|
||||
const corrections = Array.isArray(data.overtime_corrections) ? data.overtime_corrections : [];
|
||||
|
||||
if (correctionsSectionEl && correctionsListEl && correctionsHeaderEl && correctionsContentEl && correctionsIconEl && corrections.length > 0) {
|
||||
correctionsSectionEl.style.display = 'block';
|
||||
correctionsListEl.innerHTML = '';
|
||||
|
||||
corrections.forEach(c => {
|
||||
const dt = parseSqliteDatetime(c.corrected_at);
|
||||
const dateText = dt ? dt.toLocaleDateString('de-DE') : '';
|
||||
const hoursText = formatHours(c.correction_hours);
|
||||
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
|
||||
const li = document.createElement('li');
|
||||
li.textContent = reason
|
||||
? `Korrektur am ${dateText} ${hoursText} h – ${reason}`
|
||||
: `Korrektur am ${dateText} ${hoursText} h`;
|
||||
correctionsListEl.appendChild(li);
|
||||
});
|
||||
|
||||
// Standard: zugeklappt
|
||||
correctionsExpanded = false;
|
||||
correctionsContentEl.style.display = 'none';
|
||||
correctionsIconEl.style.transform = 'rotate(0deg)';
|
||||
|
||||
// Click-Handler setzen (idempotent)
|
||||
correctionsHeaderEl.onclick = toggleCorrectionsSection;
|
||||
} else if (correctionsSectionEl) {
|
||||
correctionsSectionEl.style.display = 'none';
|
||||
}
|
||||
|
||||
summaryBoxEl.style.display = 'block';
|
||||
|
||||
|
||||
@@ -94,23 +94,36 @@
|
||||
<strong>Verbleibender Urlaub:</strong> <span class="remaining-vacation-value" data-user-id="<%= employee.user.id %>">-</span> Tage
|
||||
</div>
|
||||
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
|
||||
<strong>Überstunden-Offset:</strong>
|
||||
<strong>Überstunden-Korrektur:</strong>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
class="overtime-offset-input"
|
||||
data-user-id="<%= employee.user.id %>"
|
||||
value="<%= (employee.user.overtime_offset_hours !== undefined && employee.user.overtime_offset_hours !== null) ? employee.user.overtime_offset_hours : 0 %>"
|
||||
value="0"
|
||||
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
|
||||
title="Manuelle Korrektur (positiv oder negativ) in Stunden" />
|
||||
title="Korrektur eingeben (z. B. +10 oder -20). Nach dem Speichern wird das Feld auf 0 gesetzt." />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm save-overtime-offset-btn"
|
||||
data-user-id="<%= employee.user.id %>"
|
||||
style="padding: 6px 10px; white-space: nowrap;"
|
||||
title="Überstunden-Offset speichern">
|
||||
title="Überstunden-Korrektur speichern (addiert/abzieht und protokolliert)">
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm toggle-overtime-corrections-btn"
|
||||
data-user-id="<%= employee.user.id %>"
|
||||
style="padding: 6px 10px; white-space: nowrap;"
|
||||
title="Korrektur-Historie anzeigen/ausblenden">
|
||||
Historie
|
||||
</button>
|
||||
</div>
|
||||
<div class="overtime-corrections-container" data-user-id="<%= employee.user.id %>" style="display: none; margin-top: 10px; padding: 10px 12px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
|
||||
<div class="overtime-corrections-loading" style="color: #666; font-size: 13px;">Lade Korrekturen...</div>
|
||||
<div class="overtime-corrections-empty" style="display: none; color: #999; font-size: 13px;">Keine Korrekturen vorhanden.</div>
|
||||
<ul class="overtime-corrections-list" style="margin: 8px 0 0 0; padding-left: 18px; font-size: 13px;"></ul>
|
||||
</div>
|
||||
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
|
||||
<strong>Urlaubstage-Offset:</strong>
|
||||
@@ -489,6 +502,192 @@
|
||||
loadCurrentOvertime();
|
||||
loadRemainingVacation();
|
||||
|
||||
// Überstunden-Korrektur-Historie laden/anzeigen
|
||||
function parseSqliteDatetime(value) {
|
||||
if (!value) return null;
|
||||
const s = String(value);
|
||||
if (s.includes('T')) return new Date(s);
|
||||
return new Date(s.replace(' ', 'T') + 'Z');
|
||||
}
|
||||
|
||||
function formatHours(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const sign = n > 0 ? '+' : '';
|
||||
let s = sign + n.toFixed(2);
|
||||
s = s.replace(/\.00$/, '');
|
||||
s = s.replace(/(\.\d)0$/, '$1');
|
||||
return s;
|
||||
}
|
||||
|
||||
function showOvertimeCorrectionReasonModal(opts) {
|
||||
const title = opts && opts.title ? String(opts.title) : 'Grund für die Korrektur';
|
||||
const promptText = opts && opts.prompt ? String(opts.prompt) : 'Bitte geben Sie einen Grund an, warum die Korrektur vorgenommen wird.';
|
||||
const onSubmit = opts && typeof opts.onSubmit === 'function' ? opts.onSubmit : null;
|
||||
const onCancel = opts && typeof opts.onCancel === 'function' ? opts.onCancel : null;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.style.cssText = `
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
padding: 18px 18px 14px 18px;
|
||||
`;
|
||||
|
||||
box.innerHTML = `
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 8px;">
|
||||
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">${title}</h3>
|
||||
<button type="button" data-action="close" class="btn btn-secondary btn-sm" style="padding: 6px 10px;">✕</button>
|
||||
</div>
|
||||
<div style="color:#666; font-size: 13px; margin-bottom: 10px;">${promptText}</div>
|
||||
<textarea data-role="reason" rows="4" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical;" placeholder="Grund..."></textarea>
|
||||
<div data-role="error" style="display:none; margin-top: 8px; color:#dc3545; font-size: 13px;"></div>
|
||||
<div style="display:flex; justify-content:flex-end; gap: 10px; margin-top: 12px;">
|
||||
<button type="button" data-action="cancel" class="btn btn-secondary">Abbrechen</button>
|
||||
<button type="button" data-action="submit" class="btn btn-success">Speichern</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.appendChild(box);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const textarea = box.querySelector('textarea[data-role="reason"]');
|
||||
const errorEl = box.querySelector('[data-role="error"]');
|
||||
|
||||
function close() {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
try {
|
||||
if (onCancel) onCancel();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = msg ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const reason = textarea ? textarea.value.trim() : '';
|
||||
if (!reason) {
|
||||
setError('Bitte Grund angeben.');
|
||||
if (textarea) textarea.focus();
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
if (onSubmit) {
|
||||
await onSubmit(reason);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) cancel();
|
||||
});
|
||||
box.querySelectorAll('button[data-action="close"], button[data-action="cancel"]').forEach(btn => {
|
||||
btn.addEventListener('click', cancel);
|
||||
});
|
||||
const submitBtn = box.querySelector('button[data-action="submit"]');
|
||||
if (submitBtn) submitBtn.addEventListener('click', submit);
|
||||
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') cancel();
|
||||
if ((e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOvertimeCorrectionsForUser(userId) {
|
||||
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
|
||||
if (!container) return;
|
||||
|
||||
const loadingEl = container.querySelector('.overtime-corrections-loading');
|
||||
const emptyEl = container.querySelector('.overtime-corrections-empty');
|
||||
const listEl = container.querySelector('.overtime-corrections-list');
|
||||
|
||||
if (loadingEl) loadingEl.style.display = 'block';
|
||||
if (emptyEl) emptyEl.style.display = 'none';
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-corrections`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const corrections = Array.isArray(data.corrections) ? data.corrections : [];
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || 'Fehler beim Laden der Korrekturen');
|
||||
}
|
||||
|
||||
if (corrections.length === 0) {
|
||||
if (emptyEl) emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
corrections.forEach(c => {
|
||||
const dt = parseSqliteDatetime(c.corrected_at);
|
||||
const dateText = dt ? dt.toLocaleDateString('de-DE') : '';
|
||||
const hoursText = formatHours(c.correction_hours);
|
||||
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
|
||||
const li = document.createElement('li');
|
||||
li.textContent = reason
|
||||
? `Korrektur am ${dateText} ${hoursText} h – ${reason}`
|
||||
: `Korrektur am ${dateText} ${hoursText} h`;
|
||||
if (listEl) listEl.appendChild(li);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Überstunden-Korrekturen:', e);
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = 'Fehler beim Laden der Korrekturen.';
|
||||
emptyEl.style.display = 'block';
|
||||
}
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.toggle-overtime-corrections-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const userId = this.dataset.userId;
|
||||
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
|
||||
if (!container) return;
|
||||
|
||||
const isOpen = container.style.display !== 'none' && container.style.display !== '';
|
||||
if (isOpen) {
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
container.style.display = 'block';
|
||||
|
||||
// Beim Öffnen immer neu laden (damit neue Korrekturen sofort sichtbar sind)
|
||||
await loadOvertimeCorrectionsForUser(userId);
|
||||
container.dataset.loaded = 'true';
|
||||
});
|
||||
});
|
||||
|
||||
// Überstunden-Offset speichern
|
||||
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
@@ -496,59 +695,90 @@
|
||||
const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`);
|
||||
if (!input) return;
|
||||
|
||||
if (this.dataset.modalOpen === 'true') return;
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.disabled = true;
|
||||
this.textContent = '...';
|
||||
|
||||
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
|
||||
const raw = (input.value || '').trim();
|
||||
const value = raw === '' ? '' : Number(raw);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overtime_offset_hours: value })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(data.error || 'Fehler beim Speichern des Offsets');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalisiere Input auf Zahl (Backend gibt number zurück)
|
||||
input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null)
|
||||
? Number(data.overtime_offset_hours)
|
||||
: 0;
|
||||
|
||||
// Stats für diesen User neu laden
|
||||
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
|
||||
statDivs.forEach(div => {
|
||||
// loading indicator optional wieder anzeigen
|
||||
const loading = div.querySelector('.stats-loading');
|
||||
if (loading) {
|
||||
loading.style.display = 'inline-block';
|
||||
loading.style.color = '#666';
|
||||
loading.textContent = 'Lade Statistiken...';
|
||||
}
|
||||
loadStatsForDiv(div);
|
||||
});
|
||||
loadCurrentOvertime();
|
||||
|
||||
this.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}, 900);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Offsets:', e);
|
||||
alert('Fehler beim Speichern des Offsets');
|
||||
} finally {
|
||||
if (this.textContent === '...') {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
// Wenn keine Korrektur (0), nichts tun außer UI auf 0 zu normalisieren
|
||||
if (value === 0) {
|
||||
input.value = 0;
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataset.modalOpen = 'true';
|
||||
this.disabled = true;
|
||||
|
||||
// Modal: Grund ist Pflicht
|
||||
showOvertimeCorrectionReasonModal({
|
||||
title: 'Grund für die Überstunden-Korrektur',
|
||||
prompt: `Korrektur: ${value > 0 ? '+' : ''}${value} h`,
|
||||
onCancel: () => {
|
||||
delete this.dataset.modalOpen;
|
||||
this.disabled = false;
|
||||
},
|
||||
onSubmit: async (reason) => {
|
||||
this.textContent = '...';
|
||||
try {
|
||||
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overtime_offset_hours: value, reason })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(data.error || 'Fehler beim Speichern der Korrektur');
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
delete this.dataset.modalOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalisiere Input auf Zahl (Backend gibt number zurück)
|
||||
input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null)
|
||||
? Number(data.overtime_offset_hours)
|
||||
: 0;
|
||||
|
||||
// Stats für diesen User neu laden
|
||||
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
|
||||
statDivs.forEach(div => {
|
||||
// loading indicator optional wieder anzeigen
|
||||
const loading = div.querySelector('.stats-loading');
|
||||
if (loading) {
|
||||
loading.style.display = 'inline-block';
|
||||
loading.style.color = '#666';
|
||||
loading.textContent = 'Lade Statistiken...';
|
||||
}
|
||||
loadStatsForDiv(div);
|
||||
});
|
||||
loadCurrentOvertime();
|
||||
|
||||
// Historie (falls geöffnet) aktualisieren
|
||||
const correctionsContainer = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
|
||||
if (correctionsContainer && correctionsContainer.style.display !== 'none') {
|
||||
await loadOvertimeCorrectionsForUser(userId);
|
||||
}
|
||||
|
||||
this.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
delete this.dataset.modalOpen;
|
||||
}, 900);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern der Korrektur:', e);
|
||||
alert('Fehler beim Speichern der Korrektur');
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
delete this.dataset.modalOpen;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user