diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..fd0afc5 --- /dev/null +++ b/public/js/app.js @@ -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(); + }); +})(); diff --git a/src/models/TimeEntry.js b/src/models/TimeEntry.js index d02252b..524e4a7 100644 --- a/src/models/TimeEntry.js +++ b/src/models/TimeEntry.js @@ -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 }, }, diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js index dea7205..a54ec72 100644 --- a/src/routes/dashboard.js +++ b/src/routes/dashboard.js @@ -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, diff --git a/src/utils/time.js b/src/utils/time.js index c29a1a4..50eea50 100644 --- a/src/utils/time.js +++ b/src/utils/time.js @@ -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, diff --git a/views/dashboard.ejs b/views/dashboard.ejs index bdaa45a..a1b3ce5 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -21,7 +21,7 @@
- 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.