Pausenbuchung hinzugefügt

This commit is contained in:
2026-07-02 22:07:04 +02:00
parent 6a1182f9ab
commit 914084be00
5 changed files with 183 additions and 29 deletions
+54
View File
@@ -0,0 +1,54 @@
(function () {
'use strict';
const container = document.getElementById('breaks-container');
const addBtn = document.getElementById('add-break');
if (!container || !addBtn) return;
/** Erzeugt eine neue Pausen-Zeile und gibt das DOM-Element zurück. */
function createBreakRow(start, end) {
const row = document.createElement('div');
row.className = 'break-row';
const startInput = document.createElement('input');
startInput.type = 'time';
startInput.name = 'breakStart';
startInput.value = start || '';
const dash = document.createElement('span');
dash.className = 'break-dash';
dash.textContent = '';
dash.setAttribute('aria-hidden', 'true');
const endInput = document.createElement('input');
endInput.type = 'time';
endInput.name = 'breakEnd';
endInput.value = end || '';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'break-remove';
removeBtn.setAttribute('aria-label', 'Pause entfernen');
removeBtn.setAttribute('title', 'Pause entfernen');
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => row.remove());
row.append(startInput, dash, endInput, removeBtn);
return row;
}
// Bestehende Zeilen aus dem server-seitig gerenderten Formular (Bearbeitungs-
// modus) ebenfalls mit dem Entfernen-Handler ausstatten.
container.querySelectorAll('.break-row .break-remove').forEach((btn) => {
btn.addEventListener('click', () => btn.closest('.break-row').remove());
});
// Neue Pausenzeile anfügen.
addBtn.addEventListener('click', () => {
container.appendChild(createBreakRow());
// Fokus auf das erste Input der neuen Zeile setzen.
const row = container.lastElementChild;
row?.querySelector('input[type="time"]')?.focus();
});
})();
+16 -5
View File
@@ -1,15 +1,26 @@
const mongoose = require('mongoose');
const breakSchema = new mongoose.Schema(
{
start: { type: String, required: true }, // 'HH:MM'
end: { type: String, required: true }, // 'HH:MM'
},
{ _id: false }
);
const timeEntrySchema = new mongoose.Schema(
{
date: { type: String, required: true, unique: true }, // 'YYYY-MM-DD'
startTime: { type: String, required: true }, // 'HH:MM'
endTime: { type: String, required: true }, // 'HH:MM'
startTime: { type: String, required: true }, // 'HH:MM'
endTime: { type: String, required: true }, // 'HH:MM'
// Einzelne Pausenblöcke mit Start- und Endzeit
breaks: { type: [breakSchema], default: [] },
// Aufsummierte Gesamtpausenzeit in Minuten (für Aggregationen/Anzeige)
breakMinutes: { type: Number, default: 0 },
note: { type: String, default: '' },
// Werden beim Speichern anhand der zu diesem Zeitpunkt gültigen
// Einstellungen berechnet und fest abgelegt, damit spätere Änderungen
// an den Soll-Stunden bestehende Buchungen nicht rückwirkend verändern.
// Netto-Arbeitszeit und Tagessaldo werden beim Speichern berechnet
// und fest abgelegt, damit spätere Einstellungsänderungen keine
// rückwirkende Wirkung haben.
workedMinutes: { type: Number, required: true },
balanceMinutes: { type: Number, required: true },
},
+30 -4
View File
@@ -5,6 +5,8 @@ const { requireAuth } = require('../middleware/auth');
const TimeEntry = require('../models/TimeEntry');
const Settings = require('../models/Settings');
const {
timeToMinutes,
computeTotalBreakMinutes,
computeWorkedMinutes,
computeBalanceMinutes,
formatBalance,
@@ -55,19 +57,42 @@ router.get('/', requireAuth, async (req, res) => {
});
});
/** Liest die Pausen-Arrays aus dem Formular und gibt saubere Objekte zurück. */
function parseBreaks(body) {
const starts = [].concat(body.breakStart || []).map((s) => s.trim()).filter(Boolean);
const ends = [].concat(body.breakEnd || []).map((e) => e.trim()).filter(Boolean);
const breaks = [];
for (let i = 0; i < starts.length; i++) {
const start = starts[i];
const end = ends[i];
if (!start || !end) continue;
if (timeToMinutes(end) <= timeToMinutes(start)) continue; // Endzeit vor Startzeit → überspringen
breaks.push({ start, end });
}
return breaks;
}
router.post('/entries', requireAuth, async (req, res) => {
try {
const { date, startTime, endTime, breakMinutes, note } = req.body;
const { date, startTime, endTime, note } = req.body;
if (!date || !startTime || !endTime) {
return res.redirect('/?error=Bitte+Datum%2C+Start-+und+Endzeit+angeben.');
}
const workedMinutes = computeWorkedMinutes(startTime, endTime, breakMinutes);
const breaks = parseBreaks(req.body);
const totalBreakMinutes = computeTotalBreakMinutes(breaks);
const workedMinutes = computeWorkedMinutes(startTime, endTime, breaks);
if (workedMinutes < 0) {
if (timeToMinutes(endTime) <= timeToMinutes(startTime)) {
return res.redirect('/?error=Die+Endzeit+muss+nach+der+Startzeit+liegen.');
}
if (workedMinutes < 0) {
return res.redirect(
'/?error=Die+Pausen+übersteigen+die+Gesamtarbeitszeit.+Bitte+prüfen.'
);
}
const settings = await Settings.getSingleton();
const balanceMinutes = computeBalanceMinutes(workedMinutes, date, settings);
@@ -78,7 +103,8 @@ router.post('/entries', requireAuth, async (req, res) => {
date,
startTime,
endTime,
breakMinutes: Number(breakMinutes || 0),
breaks,
breakMinutes: totalBreakMinutes,
note: note || '',
workedMinutes,
balanceMinutes,
+26 -3
View File
@@ -28,9 +28,31 @@ function isWorkDay(dateStr, workDays) {
return Array.isArray(workDays) && workDays.includes(day);
}
/** Berechnet die tatsächlich geleisteten Minuten abzüglich Pause. */
function computeWorkedMinutes(startTime, endTime, breakMinutes) {
return timeToMinutes(endTime) - timeToMinutes(startTime) - Number(breakMinutes || 0);
/**
* Summiert alle Einzelpausen.
* @param {Array<{start: string, end: string}>} breaks
* @returns {number} Gesamte Pausenzeit in Minuten
*/
function computeTotalBreakMinutes(breaks) {
if (!Array.isArray(breaks)) return 0;
return breaks.reduce((total, b) => {
if (!b || !b.start || !b.end) return total;
const duration = timeToMinutes(b.end) - timeToMinutes(b.start);
return total + Math.max(0, duration);
}, 0);
}
/**
* Berechnet die tatsächlich geleisteten Netto-Minuten.
* @param {string} startTime z. B. "08:00"
* @param {string} endTime z. B. "17:00"
* @param {Array<{start: string, end: string}>} breaks Pausenzeiten
* @returns {number}
*/
function computeWorkedMinutes(startTime, endTime, breaks) {
const gross = timeToMinutes(endTime) - timeToMinutes(startTime);
const totalBreak = computeTotalBreakMinutes(breaks);
return gross - totalBreak;
}
/** Saldo (Über-/Minusstunden) eines einzelnen Tages in Minuten. */
@@ -64,6 +86,7 @@ module.exports = {
minutesToHM,
formatBalance,
isWorkDay,
computeTotalBreakMinutes,
computeWorkedMinutes,
computeBalanceMinutes,
formatDateDisplay,
+57 -17
View File
@@ -21,7 +21,7 @@
<div class="layout">
<section class="card entry-card">
<h2 class="card__title"><%= editEntry ? 'Buchung bearbeiten' : 'Buchung erfassen' %></h2>
<form action="/entries" method="POST" class="form">
<form action="/entries" method="POST" class="form" id="entry-form">
<label class="field">
<span class="field__label">Datum</span>
<input
@@ -31,9 +31,10 @@
required
/>
</label>
<div class="field-row">
<label class="field">
<span class="field__label">Start</span>
<span class="field__label">Arbeitsbeginn</span>
<input
type="time"
name="startTime"
@@ -42,7 +43,7 @@
/>
</label>
<label class="field">
<span class="field__label">Ende</span>
<span class="field__label">Arbeitsende</span>
<input
type="time"
name="endTime"
@@ -51,16 +52,34 @@
/>
</label>
</div>
<label class="field">
<span class="field__label">Pause (Minuten)</span>
<input
type="number"
name="breakMinutes"
min="0"
step="5"
value="<%= editEntry ? editEntry.breakMinutes : 30 %>"
/>
</label>
<div class="field">
<span class="field__label">
Pausen
<span class="field__label-hint">(jeweils Start Ende)</span>
</span>
<div id="breaks-container">
<% if (editEntry && editEntry.breaks && editEntry.breaks.length > 0) { %>
<% editEntry.breaks.forEach((b) => { %>
<div class="break-row">
<input type="time" name="breakStart" value="<%= b.start %>" />
<span class="break-dash" aria-hidden="true"></span>
<input type="time" name="breakEnd" value="<%= b.end %>" />
<button
type="button"
class="break-remove"
aria-label="Pause entfernen"
title="Pause entfernen"
>×</button>
</div>
<% }) %>
<% } %>
</div>
<button type="button" id="add-break" class="button button--ghost break-add-btn">
+ Pause hinzufügen
</button>
</div>
<label class="field">
<span class="field__label">Notiz (optional)</span>
<input
@@ -71,6 +90,7 @@
placeholder="z. B. Außendienst, Homeoffice …"
/>
</label>
<button type="submit" class="button button--primary button--block">
<%= editEntry ? 'Änderung speichern' : 'Buchung speichern' %>
</button>
@@ -79,8 +99,8 @@
<% } %>
</form>
<p class="card__footnote">
Ein Tag = ein Eintrag. Eine bereits vorhandene Buchung für ein Datum wird beim erneuten
Speichern aktualisiert.
Ein Tag = ein Eintrag. Pausen mit End- vor Startzeit werden ignoriert.
Bereits bestehende Buchungen für ein Datum werden beim erneuten Speichern aktualisiert.
</p>
</section>
@@ -96,7 +116,8 @@
<th>Datum</th>
<th>Start</th>
<th>Ende</th>
<th>Pause</th>
<th>Pausen</th>
<th>Pause (ges.)</th>
<th>Gearbeitet</th>
<th>Saldo</th>
<th>Notiz</th>
@@ -109,7 +130,24 @@
<td><%= formatDateDisplay(entry.date) %></td>
<td class="num"><%= entry.startTime %></td>
<td class="num"><%= entry.endTime %></td>
<td class="num"><%= entry.breakMinutes %> min</td>
<td class="num">
<% if (entry.breaks && entry.breaks.length > 0) { %>
<span class="break-pills">
<% entry.breaks.forEach((b) => { %>
<span class="break-pill"><%= b.start %><%= b.end %></span>
<% }) %>
</span>
<% } else { %>
<span class="muted"></span>
<% } %>
</td>
<td class="num">
<% if (entry.breakMinutes > 0) { %>
<%= minutesToHM(entry.breakMinutes) %>
<% } else { %>
<span class="muted"></span>
<% } %>
</td>
<td class="num"><%= minutesToHM(entry.workedMinutes) %></td>
<td class="num <%= entry.balanceMinutes < 0 ? 'is-negative' : 'is-positive' %>">
<%= formatBalance(entry.balanceMinutes) %>
@@ -160,5 +198,7 @@
</section>
<% } %>
</main>
<script src="/js/app.js"></script>
</body>
</html>