Pausenbuchung hinzugefügt
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user