Berücksichtige lokale Datumswerte bei der Berechnung von Abwesenheiten und verbessere die Testabdeckung für Wochenansichten und Arbeitszeiten.
All checks were successful
Deploy production / deploy (push) Successful in 53s
All checks were successful
Deploy production / deploy (push) Successful in 53s
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
const database = require('../config/database');
|
const database = require('../config/database');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
|
// DATEONLY-Werte als Kalendertage behandeln: lokale setDate-Schritte auf
|
||||||
|
// UTC-Mitternacht verschieben beim Sommerzeitwechsel den letzten Tag.
|
||||||
|
const localDateKey = date => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository für Worklog-Datenbankzugriff
|
* Repository für Worklog-Datenbankzugriff
|
||||||
* Verwendet Sequelize ORM
|
* Verwendet Sequelize ORM
|
||||||
@@ -171,6 +176,8 @@ class WorklogRepository {
|
|||||||
async getVacationsByUserInDateRange(userId, startDate, endDate) {
|
async getVacationsByUserInDateRange(userId, startDate, endDate) {
|
||||||
const { Vacation } = database.getModels();
|
const { Vacation } = database.getModels();
|
||||||
|
|
||||||
|
const startKey = localDateKey(startDate);
|
||||||
|
const endKey = localDateKey(endDate);
|
||||||
try {
|
try {
|
||||||
// Hole alle Vacation-Einträge, die sich mit dem Datumsbereich überschneiden
|
// Hole alle Vacation-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||||
const vacations = await Vacation.findAll({
|
const vacations = await Vacation.findAll({
|
||||||
@@ -179,18 +186,18 @@ class WorklogRepository {
|
|||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{
|
{
|
||||||
first_day: {
|
first_day: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
last_day: {
|
last_day: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
[Op.and]: [
|
[Op.and]: [
|
||||||
{ first_day: { [Op.lte]: startDate } },
|
{ first_day: { [Op.lte]: startKey } },
|
||||||
{ last_day: { [Op.gte]: endDate } }
|
{ last_day: { [Op.gte]: endKey } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -207,11 +214,11 @@ class WorklogRepository {
|
|||||||
const last = new Date(vac.last_day);
|
const last = new Date(vac.last_day);
|
||||||
|
|
||||||
// Iteriere über alle Tage im Urlaubsbereich
|
// Iteriere über alle Tage im Urlaubsbereich
|
||||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||||
if (d >= startDate && d <= endDate) {
|
if (d.toISOString().slice(0, 10) >= startKey && d.toISOString().slice(0, 10) <= endKey) {
|
||||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||||
const dayOfWeek = d.getDay();
|
const dayOfWeek = d.getUTCDay();
|
||||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||||
expandedVacations.push({
|
expandedVacations.push({
|
||||||
date: new Date(d),
|
date: new Date(d),
|
||||||
@@ -240,6 +247,8 @@ class WorklogRepository {
|
|||||||
async getSickByUserInDateRange(userId, startDate, endDate) {
|
async getSickByUserInDateRange(userId, startDate, endDate) {
|
||||||
const { Sick, SickType } = database.getModels();
|
const { Sick, SickType } = database.getModels();
|
||||||
|
|
||||||
|
const startKey = localDateKey(startDate);
|
||||||
|
const endKey = localDateKey(endDate);
|
||||||
try {
|
try {
|
||||||
// Hole alle Sick-Einträge, die sich mit dem Datumsbereich überschneiden
|
// Hole alle Sick-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||||
const sickEntries = await Sick.findAll({
|
const sickEntries = await Sick.findAll({
|
||||||
@@ -248,18 +257,18 @@ class WorklogRepository {
|
|||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{
|
{
|
||||||
first_day: {
|
first_day: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
last_day: {
|
last_day: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
[Op.and]: [
|
[Op.and]: [
|
||||||
{ first_day: { [Op.lte]: startDate } },
|
{ first_day: { [Op.lte]: startKey } },
|
||||||
{ last_day: { [Op.gte]: endDate } }
|
{ last_day: { [Op.gte]: endKey } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -281,11 +290,11 @@ class WorklogRepository {
|
|||||||
const sickTypeDesc = sick.sickType?.description || 'self';
|
const sickTypeDesc = sick.sickType?.description || 'self';
|
||||||
|
|
||||||
// Iteriere über alle Tage im Krankheitsbereich
|
// Iteriere über alle Tage im Krankheitsbereich
|
||||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||||
if (d >= startDate && d <= endDate) {
|
if (d.toISOString().slice(0, 10) >= startKey && d.toISOString().slice(0, 10) <= endKey) {
|
||||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||||
const dayOfWeek = d.getDay();
|
const dayOfWeek = d.getUTCDay();
|
||||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||||
expandedSick.push({
|
expandedSick.push({
|
||||||
date: new Date(d),
|
date: new Date(d),
|
||||||
@@ -358,7 +367,7 @@ class WorklogRepository {
|
|||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
tstamp: {
|
tstamp: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: [['tstamp', 'ASC']],
|
order: [['tstamp', 'ASC']],
|
||||||
@@ -429,7 +438,7 @@ class WorklogRepository {
|
|||||||
where: {
|
where: {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
tstamp: {
|
tstamp: {
|
||||||
[Op.between]: [startDate, endDate]
|
[Op.between]: [startKey, endKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt
|
order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ class TimeEntryService {
|
|||||||
const userDailyHours = user?.daily_hours || 8;
|
const userDailyHours = user?.daily_hours || 8;
|
||||||
|
|
||||||
let currentlyWorked = null;
|
let currentlyWorked = null;
|
||||||
let allEntries = [];
|
|
||||||
|
|
||||||
// Hole laufenden Eintrag
|
// Hole laufenden Eintrag
|
||||||
const runningEntry = await this.getRunningEntry(uid);
|
const runningEntry = await this.getRunningEntry(uid);
|
||||||
@@ -284,13 +283,18 @@ class TimeEntryService {
|
|||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||||
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||||
|
|
||||||
|
const todayBlocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid))
|
||||||
|
.filter(block => {
|
||||||
|
const start = new Date(block.start.effectiveTime);
|
||||||
|
return start >= todayStart && start <= todayEnd;
|
||||||
|
});
|
||||||
|
|
||||||
if (runningEntry) {
|
if (runningEntry) {
|
||||||
// Arbeitszeit wird aus der korrigierten Ereignisfolge gebildet. Damit
|
// Arbeitszeit wird aus der korrigierten Ereignisfolge gebildet. Damit
|
||||||
// darf eine nachträglich umtypisierte Buchung keine Phantompause mehr
|
// darf eine nachträglich umtypisierte Buchung keine Phantompause mehr
|
||||||
// erzeugen.
|
// erzeugen.
|
||||||
const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid));
|
|
||||||
let totalWorkedMs = 0;
|
let totalWorkedMs = 0;
|
||||||
for (const block of blocks) {
|
for (const block of todayBlocks) {
|
||||||
const start = new Date(block.start.effectiveTime);
|
const start = new Date(block.start.effectiveTime);
|
||||||
if (start < todayStart || start > todayEnd) continue;
|
if (start < todayStart || start > todayEnd) continue;
|
||||||
const endMs = block.end ? new Date(block.end.effectiveTime).getTime() : now.getTime();
|
const endMs = block.end ? new Date(block.end.effectiveTime).getTime() : now.getTime();
|
||||||
@@ -419,38 +423,11 @@ class TimeEntryService {
|
|||||||
requiredBreakMinutes = 30;
|
requiredBreakMinutes = 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berechne ALLE genommenen Pausen von heute (verwende bereits geladene Einträge)
|
const takenBreakMs = todayBlocks.reduce((sum, block) => sum +
|
||||||
if (allEntries.length > 0) {
|
block.pauses.reduce((pauseSum, pause) => pauseSum +
|
||||||
// Sammle alle abgeschlossenen Pausen
|
new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0) +
|
||||||
const pauseStarts = {};
|
(block.runningPause ? now - new Date(block.runningPause.effectiveTime) : 0), 0);
|
||||||
let totalPauseMs = 0;
|
alreadyTakenBreakMinutes = Math.floor(takenBreakMs / 60000);
|
||||||
|
|
||||||
allEntries.forEach(entry => {
|
|
||||||
let state = entry.state;
|
|
||||||
if (typeof state === 'string') {
|
|
||||||
try {
|
|
||||||
state = JSON.parse(state);
|
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = state?.action || state;
|
|
||||||
|
|
||||||
if (action === 'start pause') {
|
|
||||||
pauseStarts[entry.id] = entry;
|
|
||||||
} else if (action === 'stop pause' && entry.relatedTo_id) {
|
|
||||||
const startPause = pauseStarts[entry.relatedTo_id];
|
|
||||||
if (startPause) {
|
|
||||||
const pauseDuration = new Date(entry.tstamp).getTime() - new Date(startPause.tstamp).getTime();
|
|
||||||
totalPauseMs += pauseDuration;
|
|
||||||
delete pauseStarts[entry.relatedTo_id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
alreadyTakenBreakMinutes = Math.floor(totalPauseMs / (1000 * 60));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehlende Pausenzeit
|
// Fehlende Pausenzeit
|
||||||
@@ -596,8 +573,13 @@ class TimeEntryService {
|
|||||||
const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`;
|
const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
// Wochenarbeitszeit
|
// Wochenarbeitszeit
|
||||||
const weekWorktimeHours = Math.floor(weekIstMinutes / 60);
|
const weekWorkMinutes = weekData.days.reduce((sum, day) => {
|
||||||
const weekWorktimeMins = weekIstMinutes % 60;
|
if (!day.netWorkTime) return sum;
|
||||||
|
const [h, m] = day.netWorkTime.split(':').map(Number);
|
||||||
|
return sum + h * 60 + m;
|
||||||
|
}, 0);
|
||||||
|
const weekWorktimeHours = Math.floor(weekWorkMinutes / 60);
|
||||||
|
const weekWorktimeMins = weekWorkMinutes % 60;
|
||||||
const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`;
|
const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
// Berechne "Offen für Woche"
|
// Berechne "Offen für Woche"
|
||||||
@@ -718,7 +700,7 @@ class TimeEntryService {
|
|||||||
const openH = parseInt(openParts[0]);
|
const openH = parseInt(openParts[0]);
|
||||||
const openM = parseInt(openParts[1]);
|
const openM = parseInt(openParts[1]);
|
||||||
const openS = parseInt(openParts[2] || 0);
|
const openS = parseInt(openParts[2] || 0);
|
||||||
const openMinutes = openH * 60 + openM;
|
const openMinutes = openH * 60 + openM + openS / 60;
|
||||||
|
|
||||||
// Aktuelle Zeit
|
// Aktuelle Zeit
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
@@ -1311,6 +1293,7 @@ class TimeEntryService {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id);
|
.sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id);
|
||||||
const pairs = this._buildEffectiveWorkBlocks(effectiveEntries).map(block => ({
|
const pairs = this._buildEffectiveWorkBlocks(effectiveEntries).map(block => ({
|
||||||
|
effectiveBlock: block,
|
||||||
id: block.start.id,
|
id: block.start.id,
|
||||||
end_id: block.end?.id || null,
|
end_id: block.end?.id || null,
|
||||||
start_time: block.start.tstamp,
|
start_time: block.start.tstamp,
|
||||||
@@ -1738,6 +1721,18 @@ class TimeEntryService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const block = pair.effectiveBlock;
|
||||||
|
const elapsedMs = Math.max(0, now - new Date(block.start.effectiveTime));
|
||||||
|
const pauseMs = block.pauses.reduce((sum, pause) => sum +
|
||||||
|
new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0) +
|
||||||
|
(block.runningPause ? now - new Date(block.runningPause.effectiveTime) : 0);
|
||||||
|
const formatDuration = ms => {
|
||||||
|
const minutes = Math.floor(Math.max(0, ms) / 60000);
|
||||||
|
return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
workBlock.totalWorkTime = formatDuration(elapsedMs);
|
||||||
|
workBlock.netWorkTime = formatDuration(elapsedMs - pauseMs);
|
||||||
|
|
||||||
// Füge laufenden Block zum Tag hinzu
|
// Füge laufenden Block zum Tag hinzu
|
||||||
day.workBlocks.push(workBlock);
|
day.workBlocks.push(workBlock);
|
||||||
|
|
||||||
@@ -1822,12 +1817,7 @@ class TimeEntryService {
|
|||||||
};
|
};
|
||||||
events.push(sickLabels[sick.sick_type] || 'Krank');
|
events.push(sickLabels[sick.sick_type] || 'Krank');
|
||||||
|
|
||||||
// Bei Krankheit: Lösche geloggte Arbeitszeit
|
// Tatsächlich gebuchte Arbeit bleibt zusätzlich sichtbar.
|
||||||
day.workTime = null;
|
|
||||||
day.pauses = [];
|
|
||||||
day.totalWorkTime = null;
|
|
||||||
day.pauseTimes = [];
|
|
||||||
day.netWorkTime = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe auf Feiertag
|
// Prüfe auf Feiertag
|
||||||
@@ -1886,11 +1876,11 @@ class TimeEntryService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Berechne die reguläre Arbeitszeit ohne arbeitsfreie Tage. Der
|
// Tatsächliche Arbeitszeit einschließlich laufender Blöcke.
|
||||||
// Urlaubs-/Krankheits-/Feiertagsanteil wird weiter unten separat ergänzt.
|
// Abwesenheitsgutschriften werden separat ergänzt.
|
||||||
let workMinutes = 0;
|
let workMinutes = 0;
|
||||||
Object.values(dayData).forEach(day => {
|
Object.values(dayData).forEach(day => {
|
||||||
if (day.netWorkTime && !day.vacation && !day.sick && !day.holiday) {
|
if (day.netWorkTime) {
|
||||||
const [hours, minutes] = day.netWorkTime.split(':').map(Number);
|
const [hours, minutes] = day.netWorkTime.split(':').map(Number);
|
||||||
workMinutes += hours * 60 + minutes;
|
workMinutes += hours * 60 + minutes;
|
||||||
}
|
}
|
||||||
@@ -1902,7 +1892,7 @@ class TimeEntryService {
|
|||||||
const nonWorkingDetails = [];
|
const nonWorkingDetails = [];
|
||||||
|
|
||||||
Object.values(dayData).forEach(day => {
|
Object.values(dayData).forEach(day => {
|
||||||
if (day.vacation && day.vacation.hours > 0) {
|
if (!day.sick && !day.holiday && day.vacation && day.vacation.hours > 0) {
|
||||||
nonWorkingMinutes += day.vacation.hours * 60;
|
nonWorkingMinutes += day.vacation.hours * 60;
|
||||||
nonWorkingDays++;
|
nonWorkingDays++;
|
||||||
nonWorkingDetails.push({
|
nonWorkingDetails.push({
|
||||||
@@ -1920,7 +1910,7 @@ class TimeEntryService {
|
|||||||
hours: day.sick.hours
|
hours: day.sick.hours
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (day.holiday && day.holiday.hours > 0) {
|
if (!day.sick && day.holiday && day.holiday.hours > 0) {
|
||||||
nonWorkingMinutes += day.holiday.hours * 60;
|
nonWorkingMinutes += day.holiday.hours * 60;
|
||||||
nonWorkingDays++;
|
nonWorkingDays++;
|
||||||
nonWorkingDetails.push({
|
nonWorkingDetails.push({
|
||||||
@@ -1935,10 +1925,8 @@ class TimeEntryService {
|
|||||||
const nonWorkingMins = nonWorkingMinutes % 60;
|
const nonWorkingMins = nonWorkingMinutes % 60;
|
||||||
const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`;
|
const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
// Die Wochensumme enthält die reguläre Arbeitszeit und die Gutschrift für
|
// Wochensumme = tatsächliche Arbeit; Gesamtsumme = Arbeit + Gutschriften.
|
||||||
// arbeitsfreie Tage. Die Gesamtsumme weist den Anteil arbeitsfreier Tage
|
const weekTotalMinutes = workMinutes;
|
||||||
// zusätzlich aus.
|
|
||||||
const weekTotalMinutes = workMinutes + nonWorkingMinutes;
|
|
||||||
const weekTotalHours = Math.floor(weekTotalMinutes / 60);
|
const weekTotalHours = Math.floor(weekTotalMinutes / 60);
|
||||||
const weekTotalMins = weekTotalMinutes % 60;
|
const weekTotalMins = weekTotalMinutes % 60;
|
||||||
const weekTotal = `${weekTotalHours}:${weekTotalMins.toString().padStart(2, '0')}`;
|
const weekTotal = `${weekTotalHours}:${weekTotalMins.toString().padStart(2, '0')}`;
|
||||||
@@ -1949,8 +1937,8 @@ class TimeEntryService {
|
|||||||
const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`;
|
const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
weekStart: weekStart.toISOString().split('T')[0],
|
weekStart: `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`,
|
||||||
weekEnd: weekEnd.toISOString().split('T')[0],
|
weekEnd: `${weekEnd.getFullYear()}-${String(weekEnd.getMonth() + 1).padStart(2, '0')}-${String(weekEnd.getDate()).padStart(2, '0')}`,
|
||||||
weekOffset,
|
weekOffset,
|
||||||
days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)),
|
days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)),
|
||||||
weekTotal,
|
weekTotal,
|
||||||
|
|||||||
103
backend/test/time-displays.test.js
Normal file
103
backend/test/time-displays.test.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
process.env.TZ = 'Europe/Berlin';
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const database = require('../src/config/database');
|
||||||
|
const repository = require('../src/repositories/WorklogRepository');
|
||||||
|
const service = require('../src/services/TimeEntryService');
|
||||||
|
const RealDate = Date;
|
||||||
|
|
||||||
|
function freezeTime(t, time = '2026-10-27T14:00:00') {
|
||||||
|
global.Date = class extends RealDate {
|
||||||
|
constructor(...args) { super(...(args.length ? args : [time])); }
|
||||||
|
static now() { return new RealDate(time).getTime(); }
|
||||||
|
};
|
||||||
|
t.after(() => { global.Date = RealDate; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mock(t, object, key, value) {
|
||||||
|
const original = object[key];
|
||||||
|
object[key] = value;
|
||||||
|
t.after(() => { object[key] = original; });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, model, method] of [
|
||||||
|
['Krankheit', 'Sick', 'getSickByUserInDateRange'],
|
||||||
|
['Urlaub', 'Vacation', 'getVacationsByUserInDateRange']
|
||||||
|
]) {
|
||||||
|
test(`${name}: letzter Tag nach Zeitumstellung wird berücksichtigt`, async t => {
|
||||||
|
mock(t, database, 'getModels', () => ({
|
||||||
|
[model]: { findAll: async () => [{ first_day: '2026-10-06', last_day: '2026-10-26', vacation_type: 2 }] }
|
||||||
|
}));
|
||||||
|
const days = await repository[method](1, new Date('2026-10-26T00:00:00'), new Date('2026-11-01T23:59:59'));
|
||||||
|
assert.deepEqual(days.map(day => day.date.toISOString().slice(0, 10)), ['2026-10-26']);
|
||||||
|
});
|
||||||
|
test(`${name}: eintägige Abwesenheit an der Wochengrenze`, async t => {
|
||||||
|
mock(t, database, 'getModels', () => ({
|
||||||
|
[model]: { findAll: async () => [{ first_day: '2026-10-26', last_day: '2026-10-26', vacation_type: 1 }] }
|
||||||
|
}));
|
||||||
|
const days = await repository[method](1, new Date('2026-10-26T00:00:00'), new Date('2026-11-01T23:59:59'));
|
||||||
|
assert.equal(days.length, 1);
|
||||||
|
assert.equal(days[0].date.toISOString().slice(0, 10), '2026-10-26');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWeek(t, { pause = false, sick = true, halfVacation = false } = {}) {
|
||||||
|
freezeTime(t);
|
||||||
|
const entries = [
|
||||||
|
{ id: 1, state: 'start work', tstamp: '2026-10-27 08:00:00' },
|
||||||
|
{ id: 2, state: 'start pause', tstamp: '2026-10-27 10:00:00', relatedTo_id: 1 },
|
||||||
|
...(!pause ? [{ id: 3, state: 'stop pause', tstamp: '2026-10-27 10:30:00', relatedTo_id: 2 }] : [])
|
||||||
|
];
|
||||||
|
mock(t, repository, 'findByDateRange', async () => entries);
|
||||||
|
mock(t, repository, 'findByUser', async () => entries);
|
||||||
|
mock(t, repository, 'getTimefixesByWorklogIds', async () => new Map());
|
||||||
|
mock(t, repository, 'getSickByUserInDateRange', async () => sick ? [{ date: new Date('2026-10-26'), sick_type: 'self' }] : []);
|
||||||
|
mock(t, repository, 'getVacationsByUserInDateRange', async () => halfVacation ? [{ date: new Date('2026-10-27'), half_day: 1 }] : []);
|
||||||
|
mock(t, repository, 'getHolidaysInDateRange', async () => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Wochenansicht: lokale Wochengrenzen, laufende Arbeit und Krankheit genau einmal', async t => {
|
||||||
|
setupWeek(t);
|
||||||
|
const week = await service.getWeekOverview(1);
|
||||||
|
assert.equal(week.weekStart, '2026-10-26');
|
||||||
|
assert.equal(week.weekEnd, '2026-11-01');
|
||||||
|
assert.equal(week.days[0].sick.hours, 8);
|
||||||
|
assert.equal(week.days[1].workBlocks[0].netWorkTime, '5:30');
|
||||||
|
assert.equal(week.weekTotal, '5:30');
|
||||||
|
assert.equal(week.nonWorkingTotal, '8:00');
|
||||||
|
assert.equal(week.totalAll, '13:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wochenansicht: laufende Pause friert Nettozeit ein', async t => {
|
||||||
|
setupWeek(t, { pause: true });
|
||||||
|
const week = await service.getWeekOverview(1);
|
||||||
|
assert.equal(week.days[1].workBlocks[0].netWorkTime, '2:00');
|
||||||
|
assert.equal(week.weekTotal, '2:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wochenansicht: Arbeit am halben Urlaubstag geht nicht verloren', async t => {
|
||||||
|
setupWeek(t, { sick: false, halfVacation: true });
|
||||||
|
const week = await service.getWeekOverview(1);
|
||||||
|
assert.equal(week.weekTotal, '5:30');
|
||||||
|
assert.equal(week.nonWorkingTotal, '4:00');
|
||||||
|
assert.equal(week.totalAll, '9:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Status: keine Fehlzeit für kranken Montag, Pausen angerechnet, laufende Wochenzeit', async t => {
|
||||||
|
setupWeek(t);
|
||||||
|
mock(t, database, 'getModels', () => ({
|
||||||
|
User: { findByPk: async () => ({ daily_hours: 8 }) },
|
||||||
|
Timewish: { findOne: async () => null, findAll: async () => [] }
|
||||||
|
}));
|
||||||
|
mock(t, service, '_calculateTotalOvertime', async () => ({ minutes: 349, formatted: '+5:49' }));
|
||||||
|
const stats = await service.getStatistics(1);
|
||||||
|
assert.equal(stats.currentlyWorked, '05:30:00');
|
||||||
|
assert.equal(stats.overtime, '+0:00');
|
||||||
|
assert.equal(stats.weekWorktime, '5:30');
|
||||||
|
assert.equal(stats.nonWorkingHours, '8:00');
|
||||||
|
assert.equal(stats.missingBreakMinutes, 0);
|
||||||
|
assert.equal(stats.open, '02:30:00');
|
||||||
|
assert.equal(stats.openForWeek, '2:30');
|
||||||
|
assert.equal(stats.adjustedEndTodayWeek, '16:30');
|
||||||
|
assert.equal(stats.totalOvertime, '+5:49');
|
||||||
|
});
|
||||||
@@ -88,6 +88,8 @@ const fetchStats = async () => {
|
|||||||
missingBreakMinutes.value = 0
|
missingBreakMinutes.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCurrentlyWorkedTime()
|
||||||
|
updateOpenTime()
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +166,7 @@ const updateCurrentlyWorkedTime = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verwende Server-Werte nur, wenn NICHT gearbeitet wird (oder kein laufender Start vorhanden)
|
// Verwende Server-Werte nur, wenn NICHT gearbeitet wird (oder kein laufender Start vorhanden)
|
||||||
const shouldUseServerSummary = !workStartTime.value
|
const shouldUseServerSummary = Boolean(serverWorkedTime.value)
|
||||||
|
|
||||||
if (shouldUseServerSummary && serverWorkedTime.value && serverTimestamp.value) {
|
if (shouldUseServerSummary && serverWorkedTime.value && serverTimestamp.value) {
|
||||||
// Parse Server-Zeit (HH:MM:SS)
|
// Parse Server-Zeit (HH:MM:SS)
|
||||||
@@ -406,11 +408,9 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const displayRows = computed(() => {
|
const displayRows = computed(() => {
|
||||||
// Verwende Server-Wert für "Derzeit gearbeitet" wenn verfügbar (zeigt gesamte Tagesarbeitszeit)
|
const workedDisplay = currentlyWorkedTime.value === '—'
|
||||||
const workedDisplay = (stats.value?.currentlyWorked && stats.value.currentlyWorked !== '—')
|
? '—' : currentlyWorkedTime.value + ' h';
|
||||||
? stats.value.currentlyWorked + ' h'
|
|
||||||
: (currentlyWorkedTime.value === '—' ? currentlyWorkedTime.value : currentlyWorkedTime.value + ' h');
|
|
||||||
|
|
||||||
const rows = {
|
const rows = {
|
||||||
'Derzeit gearbeitet': workedDisplay,
|
'Derzeit gearbeitet': workedDisplay,
|
||||||
'Offen': openTime.value === '—' || openTime.value.includes('erreicht') ? openTime.value : openTime.value + ' h',
|
'Offen': openTime.value === '—' || openTime.value.includes('erreicht') ? openTime.value : openTime.value + ' h',
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ async function createSick() {
|
|||||||
|
|
||||||
// Liste neu laden
|
// Liste neu laden
|
||||||
await loadSickEntries()
|
await loadSickEntries()
|
||||||
|
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erstellen des Krankheitseintrags:', error)
|
console.error('Fehler beim Erstellen des Krankheitseintrags:', error)
|
||||||
await alert(`Fehler: ${error.message}`, 'Fehler')
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
@@ -227,6 +228,7 @@ async function deleteSick(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadSickEntries()
|
await loadSickEntries()
|
||||||
|
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Löschen des Krankheitseintrags:', error)
|
console.error('Fehler beim Löschen des Krankheitseintrags:', error)
|
||||||
await alert(`Fehler: ${error.message}`, 'Fehler')
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ async function createVacation() {
|
|||||||
|
|
||||||
resetForm()
|
resetForm()
|
||||||
await loadVacations()
|
await loadVacations()
|
||||||
|
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Erstellen des Urlaubseintrags:', error)
|
console.error('Fehler beim Erstellen des Urlaubseintrags:', error)
|
||||||
await alert(`Fehler: ${error.message}`, 'Fehler')
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
@@ -210,6 +211,7 @@ async function deleteVacation(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadVacations()
|
await loadVacations()
|
||||||
|
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Löschen des Urlaubseintrags:', error)
|
console.error('Fehler beim Löschen des Urlaubseintrags:', error)
|
||||||
await alert(`Fehler: ${error.message}`, 'Fehler')
|
await alert(`Fehler: ${error.message}`, 'Fehler')
|
||||||
|
|||||||
@@ -103,8 +103,7 @@
|
|||||||
<div v-if="block.totalWorkTime" class="total-work-time">
|
<div v-if="block.totalWorkTime" class="total-work-time">
|
||||||
{{ block.totalWorkTime }}
|
{{ block.totalWorkTime }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Laufende Blöcke haben keine Gesamtarbeitszeit. Der Platzhalter hält
|
<!-- Platzhalter für ältere API-Antworten ohne laufende Dauer. -->
|
||||||
die anschließenden Pausensummen auf Höhe der Pausenzeit. -->
|
|
||||||
<div v-else-if="block.pauseTimes && block.pauseTimes.length > 0" class="total-work-time" aria-hidden="true">
|
<div v-else-if="block.pauseTimes && block.pauseTimes.length > 0" class="total-work-time" aria-hidden="true">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -204,6 +203,7 @@ const authStore = useAuthStore()
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const weekData = ref(null)
|
const weekData = ref(null)
|
||||||
const weekOffset = ref(0)
|
const weekOffset = ref(0)
|
||||||
|
let refreshInterval = null
|
||||||
|
|
||||||
const weekDays = computed(() => {
|
const weekDays = computed(() => {
|
||||||
if (!weekData.value) return []
|
if (!weekData.value) return []
|
||||||
@@ -237,9 +237,9 @@ const formatDate = (dateString) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadWeekData = async () => {
|
const loadWeekData = async (background = false) => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
if (!background) loading.value = true
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/week-overview?weekOffset=${weekOffset.value}`, {
|
const response = await fetch(`${API_URL}/week-overview?weekOffset=${weekOffset.value}`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@@ -381,12 +381,14 @@ const handleWorklogUpdate = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadWeekData()
|
loadWeekData()
|
||||||
|
refreshInterval = setInterval(() => loadWeekData(true), 60000)
|
||||||
|
|
||||||
// Event-Listener für Worklog-Updates
|
// Event-Listener für Worklog-Updates
|
||||||
window.addEventListener('worklog-updated', handleWorklogUpdate)
|
window.addEventListener('worklog-updated', handleWorklogUpdate)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
window.removeEventListener('worklog-updated', handleWorklogUpdate)
|
window.removeEventListener('worklog-updated', handleWorklogUpdate)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user