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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
+14
-3
@@ -1,15 +1,26 @@
|
|||||||
const mongoose = require('mongoose');
|
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(
|
const timeEntrySchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
date: { type: String, required: true, unique: true }, // 'YYYY-MM-DD'
|
date: { type: String, required: true, unique: true }, // 'YYYY-MM-DD'
|
||||||
startTime: { type: String, required: true }, // 'HH:MM'
|
startTime: { type: String, required: true }, // 'HH:MM'
|
||||||
endTime: { 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 },
|
breakMinutes: { type: Number, default: 0 },
|
||||||
note: { type: String, default: '' },
|
note: { type: String, default: '' },
|
||||||
// Werden beim Speichern anhand der zu diesem Zeitpunkt gültigen
|
// Netto-Arbeitszeit und Tagessaldo werden beim Speichern berechnet
|
||||||
// Einstellungen berechnet und fest abgelegt, damit spätere Änderungen
|
// und fest abgelegt, damit spätere Einstellungsänderungen keine
|
||||||
// an den Soll-Stunden bestehende Buchungen nicht rückwirkend verändern.
|
// rückwirkende Wirkung haben.
|
||||||
workedMinutes: { type: Number, required: true },
|
workedMinutes: { type: Number, required: true },
|
||||||
balanceMinutes: { 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 TimeEntry = require('../models/TimeEntry');
|
||||||
const Settings = require('../models/Settings');
|
const Settings = require('../models/Settings');
|
||||||
const {
|
const {
|
||||||
|
timeToMinutes,
|
||||||
|
computeTotalBreakMinutes,
|
||||||
computeWorkedMinutes,
|
computeWorkedMinutes,
|
||||||
computeBalanceMinutes,
|
computeBalanceMinutes,
|
||||||
formatBalance,
|
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) => {
|
router.post('/entries', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { date, startTime, endTime, breakMinutes, note } = req.body;
|
const { date, startTime, endTime, note } = req.body;
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
if (!date || !startTime || !endTime) {
|
||||||
return res.redirect('/?error=Bitte+Datum%2C+Start-+und+Endzeit+angeben.');
|
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.');
|
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 settings = await Settings.getSingleton();
|
||||||
const balanceMinutes = computeBalanceMinutes(workedMinutes, date, settings);
|
const balanceMinutes = computeBalanceMinutes(workedMinutes, date, settings);
|
||||||
@@ -78,7 +103,8 @@ router.post('/entries', requireAuth, async (req, res) => {
|
|||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
breakMinutes: Number(breakMinutes || 0),
|
breaks,
|
||||||
|
breakMinutes: totalBreakMinutes,
|
||||||
note: note || '',
|
note: note || '',
|
||||||
workedMinutes,
|
workedMinutes,
|
||||||
balanceMinutes,
|
balanceMinutes,
|
||||||
|
|||||||
+26
-3
@@ -28,9 +28,31 @@ function isWorkDay(dateStr, workDays) {
|
|||||||
return Array.isArray(workDays) && workDays.includes(day);
|
return Array.isArray(workDays) && workDays.includes(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Berechnet die tatsächlich geleisteten Minuten abzüglich Pause. */
|
/**
|
||||||
function computeWorkedMinutes(startTime, endTime, breakMinutes) {
|
* Summiert alle Einzelpausen.
|
||||||
return timeToMinutes(endTime) - timeToMinutes(startTime) - Number(breakMinutes || 0);
|
* @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. */
|
/** Saldo (Über-/Minusstunden) eines einzelnen Tages in Minuten. */
|
||||||
@@ -64,6 +86,7 @@ module.exports = {
|
|||||||
minutesToHM,
|
minutesToHM,
|
||||||
formatBalance,
|
formatBalance,
|
||||||
isWorkDay,
|
isWorkDay,
|
||||||
|
computeTotalBreakMinutes,
|
||||||
computeWorkedMinutes,
|
computeWorkedMinutes,
|
||||||
computeBalanceMinutes,
|
computeBalanceMinutes,
|
||||||
formatDateDisplay,
|
formatDateDisplay,
|
||||||
|
|||||||
+57
-17
@@ -21,7 +21,7 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<section class="card entry-card">
|
<section class="card entry-card">
|
||||||
<h2 class="card__title"><%= editEntry ? 'Buchung bearbeiten' : 'Buchung erfassen' %></h2>
|
<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">
|
<label class="field">
|
||||||
<span class="field__label">Datum</span>
|
<span class="field__label">Datum</span>
|
||||||
<input
|
<input
|
||||||
@@ -31,9 +31,10 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Start</span>
|
<span class="field__label">Arbeitsbeginn</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
name="startTime"
|
name="startTime"
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Ende</span>
|
<span class="field__label">Arbeitsende</span>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
name="endTime"
|
name="endTime"
|
||||||
@@ -51,16 +52,34 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="field">
|
|
||||||
<span class="field__label">Pause (Minuten)</span>
|
<div class="field">
|
||||||
<input
|
<span class="field__label">
|
||||||
type="number"
|
Pausen
|
||||||
name="breakMinutes"
|
<span class="field__label-hint">(jeweils Start – Ende)</span>
|
||||||
min="0"
|
</span>
|
||||||
step="5"
|
<div id="breaks-container">
|
||||||
value="<%= editEntry ? editEntry.breakMinutes : 30 %>"
|
<% if (editEntry && editEntry.breaks && editEntry.breaks.length > 0) { %>
|
||||||
/>
|
<% editEntry.breaks.forEach((b) => { %>
|
||||||
</label>
|
<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">
|
<label class="field">
|
||||||
<span class="field__label">Notiz (optional)</span>
|
<span class="field__label">Notiz (optional)</span>
|
||||||
<input
|
<input
|
||||||
@@ -71,6 +90,7 @@
|
|||||||
placeholder="z. B. Außendienst, Homeoffice …"
|
placeholder="z. B. Außendienst, Homeoffice …"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" class="button button--primary button--block">
|
<button type="submit" class="button button--primary button--block">
|
||||||
<%= editEntry ? 'Änderung speichern' : 'Buchung speichern' %>
|
<%= editEntry ? 'Änderung speichern' : 'Buchung speichern' %>
|
||||||
</button>
|
</button>
|
||||||
@@ -79,8 +99,8 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</form>
|
</form>
|
||||||
<p class="card__footnote">
|
<p class="card__footnote">
|
||||||
Ein Tag = ein Eintrag. Eine bereits vorhandene Buchung für ein Datum wird beim erneuten
|
Ein Tag = ein Eintrag. Pausen mit End- vor Startzeit werden ignoriert.
|
||||||
Speichern aktualisiert.
|
Bereits bestehende Buchungen für ein Datum werden beim erneuten Speichern aktualisiert.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -96,7 +116,8 @@
|
|||||||
<th>Datum</th>
|
<th>Datum</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>Ende</th>
|
<th>Ende</th>
|
||||||
<th>Pause</th>
|
<th>Pausen</th>
|
||||||
|
<th>Pause (ges.)</th>
|
||||||
<th>Gearbeitet</th>
|
<th>Gearbeitet</th>
|
||||||
<th>Saldo</th>
|
<th>Saldo</th>
|
||||||
<th>Notiz</th>
|
<th>Notiz</th>
|
||||||
@@ -109,7 +130,24 @@
|
|||||||
<td><%= formatDateDisplay(entry.date) %></td>
|
<td><%= formatDateDisplay(entry.date) %></td>
|
||||||
<td class="num"><%= entry.startTime %></td>
|
<td class="num"><%= entry.startTime %></td>
|
||||||
<td class="num"><%= entry.endTime %></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"><%= minutesToHM(entry.workedMinutes) %></td>
|
||||||
<td class="num <%= entry.balanceMinutes < 0 ? 'is-negative' : 'is-positive' %>">
|
<td class="num <%= entry.balanceMinutes < 0 ? 'is-negative' : 'is-positive' %>">
|
||||||
<%= formatBalance(entry.balanceMinutes) %>
|
<%= formatBalance(entry.balanceMinutes) %>
|
||||||
@@ -160,5 +198,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user