2430 lines
87 KiB
JavaScript
2430 lines
87 KiB
JavaScript
const worklogRepository = require('../repositories/WorklogRepository');
|
|
const userRepository = require('../repositories/UserRepository');
|
|
const database = require('../config/database');
|
|
|
|
/**
|
|
* Service-Klasse für Zeiteinträge
|
|
* Enthält die gesamte Business-Logik
|
|
* Verwendet das Repository-Pattern für Datenbankzugriff
|
|
*/
|
|
class TimeEntryService {
|
|
constructor() {
|
|
// Default-Benutzer-ID (kann später durch Auth ersetzt werden)
|
|
this.defaultUserId = 1;
|
|
}
|
|
|
|
/**
|
|
* Benutzer-ID setzen (für zukünftige Auth-Integration)
|
|
* @param {number} userId - ID des aktuellen Benutzers
|
|
*/
|
|
setCurrentUser(userId) {
|
|
this.defaultUserId = userId;
|
|
}
|
|
|
|
/**
|
|
* Alle Zeiteinträge abrufen
|
|
* @param {number} userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Array>} Liste der Zeiteinträge
|
|
*/
|
|
async getAllEntries(userId = null) {
|
|
const uid = userId || this.defaultUserId;
|
|
const pairs = await worklogRepository.findPairsByUser(uid);
|
|
const safePairs = Array.isArray(pairs) ? pairs : [];
|
|
|
|
return safePairs.map(pair => this._mapWorklogPairToTimeEntry(pair));
|
|
}
|
|
|
|
/**
|
|
* Einzelnen Zeiteintrag anhand der ID abrufen
|
|
* @param {number} id - Die ID des Zeiteintrags (start_id)
|
|
* @returns {Promise<Object|null>} Der gefundene Zeiteintrag oder null
|
|
*/
|
|
async getEntryById(id) {
|
|
const worklog = await worklogRepository.findById(id);
|
|
if (!worklog) return null;
|
|
|
|
// Wenn es ein Start-Eintrag ist, zugehörigen End-Eintrag suchen
|
|
if (worklog.relatedTo_id === null) {
|
|
const pairs = await worklogRepository.findPairsByUser(worklog.user_id);
|
|
const safePairs = Array.isArray(pairs) ? pairs : [];
|
|
const pair = safePairs.find(p => p.start_id === parseInt(id));
|
|
|
|
if (pair) {
|
|
return this._mapWorklogPairToTimeEntry(pair);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Neuen Zeiteintrag erstellen (Timer starten)
|
|
* @param {Object} entryData - Daten für den neuen Eintrag
|
|
* @param {string} entryData.description - Beschreibung der Tätigkeit
|
|
* @param {string} entryData.project - Projektname
|
|
* @param {number} entryData.userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Object>} Der erstellte Zeiteintrag
|
|
* @throws {Error} Bei ungültigen Eingabedaten
|
|
*/
|
|
async createEntry(entryData) {
|
|
const userId = entryData.userId || this.defaultUserId;
|
|
const { description = '', project = 'Allgemein' } = entryData;
|
|
|
|
// Validierung: Prüfen ob bereits ein Timer läuft
|
|
const runningEntry = await worklogRepository.findRunningByUser(userId);
|
|
if (runningEntry) {
|
|
throw new Error('Es läuft bereits ein Timer. Bitte stoppen Sie diesen zuerst.');
|
|
}
|
|
|
|
// State-String mit zusätzlichen Informationen
|
|
const state = JSON.stringify({
|
|
action: 'Clock In',
|
|
project,
|
|
description
|
|
});
|
|
|
|
// Worklog-Eintrag erstellen
|
|
const worklog = await worklogRepository.create({
|
|
user_id: userId,
|
|
state,
|
|
tstamp: new Date(),
|
|
relatedTo_id: null
|
|
});
|
|
|
|
// Als TimeEntry-Format zurückgeben
|
|
return {
|
|
id: worklog.id,
|
|
startTime: worklog.tstamp,
|
|
endTime: null,
|
|
description,
|
|
project,
|
|
duration: null,
|
|
isRunning: true,
|
|
userId: worklog.user_id
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern)
|
|
* @param {number} id - Die ID des zu aktualisierenden Eintrags
|
|
* @param {Object} updateData - Zu aktualisierende Daten
|
|
* @param {string} updateData.endTime - Endzeit (ISO 8601)
|
|
* @param {string} updateData.description - Neue Beschreibung
|
|
* @param {string} updateData.project - Neuer Projektname
|
|
* @returns {Promise<Object>} Der aktualisierte Zeiteintrag
|
|
* @throws {Error} Wenn der Eintrag nicht gefunden wurde
|
|
*/
|
|
async updateEntry(id, updateData) {
|
|
const startWorklog = await worklogRepository.findById(id);
|
|
|
|
if (!startWorklog) {
|
|
throw new Error(`Eintrag mit ID ${id} nicht gefunden`);
|
|
}
|
|
|
|
// State-Daten extrahieren
|
|
let stateData;
|
|
try {
|
|
stateData = JSON.parse(startWorklog.state);
|
|
} catch {
|
|
stateData = { action: 'Clock In', project: 'Allgemein', description: '' };
|
|
}
|
|
|
|
const { endTime, description, project } = updateData;
|
|
|
|
// Timer stoppen
|
|
if (endTime) {
|
|
const endTimestamp = new Date(endTime);
|
|
const startTimestamp = new Date(startWorklog.tstamp);
|
|
|
|
// Validierung: Endzeit muss nach Startzeit liegen
|
|
if (endTimestamp <= startTimestamp) {
|
|
throw new Error('Endzeit muss nach Startzeit liegen');
|
|
}
|
|
|
|
// End-State erstellen
|
|
const endState = JSON.stringify({
|
|
action: 'Clock Out',
|
|
project: project || stateData.project,
|
|
description: description !== undefined ? description : stateData.description
|
|
});
|
|
|
|
// Clock-Out Worklog erstellen
|
|
await worklogRepository.create({
|
|
user_id: startWorklog.user_id,
|
|
state: endState,
|
|
tstamp: endTimestamp,
|
|
relatedTo_id: id
|
|
});
|
|
|
|
// Aktualisiertes Paar abrufen
|
|
const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id);
|
|
const safePairs = Array.isArray(pairs) ? pairs : [];
|
|
const pair = safePairs.find(p => p.start_id === parseInt(id));
|
|
|
|
return this._mapWorklogPairToTimeEntry(pair);
|
|
}
|
|
|
|
// Nur Beschreibung/Projekt aktualisieren (ohne Timer zu stoppen)
|
|
if (description !== undefined || project !== undefined) {
|
|
const updatedState = JSON.stringify({
|
|
...stateData,
|
|
project: project !== undefined ? project : stateData.project,
|
|
description: description !== undefined ? description : stateData.description
|
|
});
|
|
|
|
await worklogRepository.update(id, { state: updatedState });
|
|
return await this.getEntryById(id);
|
|
}
|
|
|
|
return await this.getEntryById(id);
|
|
}
|
|
|
|
/**
|
|
* Zeiteintrag löschen
|
|
* @param {number} id - Die ID des zu löschenden Eintrags
|
|
* @returns {Promise<boolean>} true wenn erfolgreich gelöscht
|
|
* @throws {Error} Wenn der Eintrag nicht gefunden wurde
|
|
*/
|
|
async deleteEntry(id) {
|
|
const startWorklog = await worklogRepository.findById(id);
|
|
|
|
if (!startWorklog) {
|
|
throw new Error(`Eintrag mit ID ${id} nicht gefunden`);
|
|
}
|
|
|
|
// Zugehörigen End-Eintrag finden und löschen
|
|
const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id);
|
|
const pair = pairs.find(p => p.start_id === parseInt(id));
|
|
|
|
if (pair && pair.end_id) {
|
|
await worklogRepository.delete(pair.end_id);
|
|
}
|
|
|
|
// Start-Eintrag löschen
|
|
return await worklogRepository.delete(id);
|
|
}
|
|
|
|
/**
|
|
* Statistiken über alle Zeiteinträge abrufen
|
|
* @param {number} userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Object>} Statistik-Objekt mit verschiedenen Metriken
|
|
*/
|
|
async getStatistics(userId = null) {
|
|
const uid = userId || this.defaultUserId;
|
|
|
|
// Hole User-Daten (für daily_hours Fallback)
|
|
const { User } = database.getModels();
|
|
const user = await User.findByPk(uid, { attributes: ['daily_hours'], raw: true });
|
|
const userDailyHours = user?.daily_hours || 8;
|
|
|
|
let currentlyWorked = null;
|
|
let allEntries = [];
|
|
|
|
// Hole laufenden Eintrag
|
|
const runningEntry = await this.getRunningEntry(uid);
|
|
|
|
const now = new Date();
|
|
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);
|
|
|
|
if (runningEntry) {
|
|
// Berechne ALLE Arbeitszeiten des heutigen Tages
|
|
// Hole alle Einträge von heute (wird auch für Pausen verwendet)
|
|
allEntries = await worklogRepository.findByDateRange(uid, todayStart, todayEnd);
|
|
|
|
// Finde alle Start-Work-Paare von heute
|
|
const workBlocks = [];
|
|
const startWorks = {};
|
|
|
|
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 work') {
|
|
startWorks[entry.id] = {
|
|
id: entry.id,
|
|
startTime: entry.tstamp,
|
|
endTime: null,
|
|
pauses: []
|
|
};
|
|
} else if (action === 'stop work' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) {
|
|
startWorks[entry.relatedTo_id].endTime = entry.tstamp;
|
|
}
|
|
});
|
|
|
|
// Sammle Pausen für jeden Block
|
|
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' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) {
|
|
startWorks[entry.relatedTo_id].pauses.push({
|
|
id: entry.id,
|
|
startTime: entry.tstamp,
|
|
endTime: null
|
|
});
|
|
} else if (action === 'stop pause' && entry.relatedTo_id) {
|
|
// Finde das zugehörige start pause
|
|
Object.values(startWorks).forEach(block => {
|
|
const pause = block.pauses.find(p => p.id === entry.relatedTo_id);
|
|
if (pause) {
|
|
pause.endTime = entry.tstamp;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Berechne Gesamtarbeitszeit aller Blöcke
|
|
let totalWorkedMs = 0;
|
|
|
|
Object.values(startWorks).forEach(block => {
|
|
const blockStart = new Date(block.startTime).getTime();
|
|
const blockEnd = block.endTime ? new Date(block.endTime).getTime() : now.getTime();
|
|
|
|
let blockWorkedMs = blockEnd - blockStart;
|
|
|
|
// Ziehe abgeschlossene Pausen ab
|
|
block.pauses.forEach(pause => {
|
|
if (pause.endTime) {
|
|
const pauseDuration = new Date(pause.endTime).getTime() - new Date(pause.startTime).getTime();
|
|
blockWorkedMs -= pauseDuration;
|
|
} else {
|
|
// Laufende Pause
|
|
const pauseDuration = now.getTime() - new Date(pause.startTime).getTime();
|
|
blockWorkedMs -= pauseDuration;
|
|
}
|
|
});
|
|
|
|
totalWorkedMs += blockWorkedMs;
|
|
});
|
|
|
|
// Formatiere als HH:MM:SS
|
|
const totalSeconds = Math.floor(totalWorkedMs / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
currentlyWorked = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Berechne "Offen" basierend auf timewish (oder Standard: 8 Stunden)
|
|
// WICHTIG: Diese Zeit wird OHNE fehlende Pausen berechnet
|
|
// Das Frontend addiert die fehlenden Pausen dann zur "Normales Arbeitsende" Berechnung
|
|
let open = null;
|
|
const { Timewish } = database.getModels();
|
|
|
|
// now wurde bereits oben deklariert (Zeile 221)
|
|
const currentDayOfWeek = now.getDay(); // 0=Sonntag, 1=Montag, ..., 6=Samstag
|
|
// Konvertiere: 0->7 (Sonntag), 1->1 (Montag), ..., 6->6 (Samstag)
|
|
const timewishDay = currentDayOfWeek === 0 ? 7 : currentDayOfWeek;
|
|
|
|
const timewish = await Timewish.findOne({
|
|
where: {
|
|
user_id: uid,
|
|
day: timewishDay
|
|
},
|
|
raw: true
|
|
});
|
|
|
|
// Berechne "Offen" nur wenn aktuell gearbeitet wird
|
|
if (currentlyWorked) {
|
|
if (timewish) {
|
|
// Timewish vorhanden - verwende Wunschzeit
|
|
if (timewish.wishtype === 1) {
|
|
// Ende nach Uhrzeit
|
|
if (timewish.end_time) {
|
|
// Parse end_time (HH:MM:SS)
|
|
const [endHour, endMinute] = timewish.end_time.split(':').map(Number);
|
|
const endTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endHour, endMinute, 0);
|
|
|
|
const remainingMs = endTime.getTime() - now.getTime();
|
|
|
|
if (remainingMs > 0) {
|
|
const remainingSeconds = Math.floor(remainingMs / 1000);
|
|
const h = Math.floor(remainingSeconds / 3600);
|
|
const m = Math.floor((remainingSeconds % 3600) / 60);
|
|
const s = remainingSeconds % 60;
|
|
open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
} else {
|
|
open = '00:00:00'; // Bereits überschritten
|
|
}
|
|
}
|
|
} else if (timewish.wishtype === 2) {
|
|
// Ende nach Stunden
|
|
if (timewish.hours) {
|
|
// Parse currentlyWorked
|
|
const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number);
|
|
const workedSeconds = workedH * 3600 + workedM * 60 + workedS;
|
|
const wishSeconds = Math.floor(timewish.hours * 3600);
|
|
|
|
const remainingSeconds = wishSeconds - workedSeconds;
|
|
|
|
if (remainingSeconds > 0) {
|
|
const h = Math.floor(remainingSeconds / 3600);
|
|
const m = Math.floor((remainingSeconds % 3600) / 60);
|
|
const s = remainingSeconds % 60;
|
|
open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
} else {
|
|
open = '00:00:00'; // Bereits erreicht
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Kein Timewish - Standard: 8 Stunden
|
|
const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number);
|
|
const workedSeconds = workedH * 3600 + workedM * 60 + workedS;
|
|
const standardSeconds = 8 * 3600; // 8 Stunden
|
|
|
|
const remainingSeconds = standardSeconds - workedSeconds;
|
|
|
|
if (remainingSeconds > 0) {
|
|
const h = Math.floor(remainingSeconds / 3600);
|
|
const m = Math.floor((remainingSeconds % 3600) / 60);
|
|
const s = remainingSeconds % 60;
|
|
open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
} else {
|
|
open = '00:00:00'; // Bereits erreicht
|
|
}
|
|
}
|
|
}
|
|
|
|
// Berechne gesetzlich erforderliche Pausen
|
|
let requiredBreakMinutes = 0;
|
|
let alreadyTakenBreakMinutes = 0;
|
|
|
|
if (currentlyWorked) {
|
|
// Geplante Arbeitszeit
|
|
let plannedWorkHours = 8; // Standard
|
|
|
|
if (timewish && timewish.wishtype === 2 && timewish.hours) {
|
|
plannedWorkHours = timewish.hours;
|
|
}
|
|
|
|
// Gesetzliche Pausen: ab 6h -> 30min, ab 9h -> 45min
|
|
if (plannedWorkHours >= 9) {
|
|
requiredBreakMinutes = 45;
|
|
} else if (plannedWorkHours >= 6) {
|
|
requiredBreakMinutes = 30;
|
|
}
|
|
|
|
// Berechne ALLE genommenen Pausen von heute (verwende bereits geladene Einträge)
|
|
if (allEntries.length > 0) {
|
|
// Sammle alle abgeschlossenen Pausen
|
|
const pauseStarts = {};
|
|
let totalPauseMs = 0;
|
|
|
|
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
|
|
const missingBreakMinutes = Math.max(0, requiredBreakMinutes - alreadyTakenBreakMinutes);
|
|
|
|
// Addiere fehlende Pausen zur "Offen" Zeit
|
|
if (open && open !== '—' && open !== 'Arbeitsende erreicht') {
|
|
const openParts = open.split(':');
|
|
const openH = parseInt(openParts[0]);
|
|
const openM = parseInt(openParts[1]);
|
|
const openS = parseInt(openParts[2] || 0);
|
|
const openMinutes = openH * 60 + openM + missingBreakMinutes;
|
|
|
|
const newOpenH = Math.floor(openMinutes / 60);
|
|
const newOpenM = openMinutes % 60;
|
|
const newOpenS = openS;
|
|
|
|
console.log(`🔍 DEBUG Offen-Berechnung: Original=${open}, Fehlende Pausen=${missingBreakMinutes}min, Neu=${newOpenH}:${newOpenM.toString().padStart(2, '0')}:${newOpenS.toString().padStart(2, '0')}`);
|
|
|
|
open = `${newOpenH.toString().padStart(2, '0')}:${newOpenM.toString().padStart(2, '0')}:${newOpenS.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Berechne Überstunden über den gesamten Zeitraum (alle Wochen)
|
|
// Neue Berechnung: Timewish-basiert
|
|
const totalOvertimeResult = await this._calculateTotalOvertime(uid, runningEntry);
|
|
|
|
// Berechne Überstunden für die aktuelle Woche
|
|
const weekData = await this.getWeekOverview(uid, 0);
|
|
|
|
let weekSollMinutes = 0; // Soll-Arbeitszeit für die Woche
|
|
let weekIstMinutes = 0; // Ist-Arbeitszeit für die Woche
|
|
|
|
// Hole alle Timewishes für Mo-Fr
|
|
const allTimewishes = await Timewish.findAll({
|
|
where: {
|
|
user_id: uid,
|
|
day: {
|
|
[require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr
|
|
}
|
|
},
|
|
raw: true
|
|
});
|
|
|
|
// Erstelle Map: Tag -> Array von Timewishes
|
|
const timewishMap = new Map();
|
|
allTimewishes.forEach(tw => {
|
|
if (!timewishMap.has(tw.day)) {
|
|
timewishMap.set(tw.day, []);
|
|
}
|
|
timewishMap.get(tw.day).push(tw);
|
|
});
|
|
|
|
// Nur abgeschlossene Tage berücksichtigen (nicht heute, wenn noch gearbeitet wird)
|
|
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const includeTodayInOvertime = !runningEntry; // Nur wenn heute schon abgeschlossen ist
|
|
|
|
weekData.days.forEach(day => {
|
|
// Parse day.date (YYYY-MM-DD) - mit expliziter Zeit um Midnight
|
|
const dayDate = new Date(day.date + 'T00:00:00');
|
|
const dayDateOnly = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate());
|
|
|
|
const isToday = dayDateOnly.getTime() === todayDate.getTime();
|
|
|
|
// Zukünftige Tage überspringen
|
|
if (dayDateOnly > todayDate) {
|
|
return;
|
|
}
|
|
|
|
// Heute überspringen, wenn noch gearbeitet wird
|
|
if (isToday && !includeTodayInOvertime) {
|
|
return;
|
|
}
|
|
|
|
const dayOfWeek = dayDate.getDay(); // 0=So, 1=Mo, ..., 6=Sa
|
|
|
|
// Nur Mo-Fr berücksichtigen
|
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
return; // Wochenende überspringen
|
|
}
|
|
|
|
// Prüfe auf Feiertag, Urlaub, Krankheit
|
|
if (day.sick) {
|
|
// Krankheitstag: Soll = 0, Ist wird ignoriert
|
|
return;
|
|
}
|
|
|
|
if (day.holiday) {
|
|
// Feiertag: Soll = 0
|
|
return;
|
|
}
|
|
|
|
if (day.vacation && !day.workBlocks?.length) {
|
|
// Voller Urlaubstag ohne Arbeit: Soll = 0
|
|
return;
|
|
}
|
|
|
|
// Berechne Soll-Zeit für diesen Tag
|
|
const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek;
|
|
const dayTimewishes = timewishMap.get(timewishDay) || [];
|
|
|
|
// Finde den passenden Timewish für dieses Datum
|
|
const applicableTimewish = dayTimewishes.find(tw => {
|
|
const startDate = new Date(tw.start_date);
|
|
const endDate = tw.end_date ? new Date(tw.end_date) : null;
|
|
|
|
// Prüfe ob dayDate im Gültigkeitsbereich liegt
|
|
if (dayDate < startDate) return false;
|
|
if (endDate && dayDate > endDate) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
let daySollHours = userDailyHours; // Fallback: User's daily_hours
|
|
if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) {
|
|
daySollHours = applicableTimewish.hours;
|
|
}
|
|
|
|
// Bei halbem Urlaubstag: Soll halbiert
|
|
if (day.vacation && day.vacation.halfDay) {
|
|
daySollHours = daySollHours / 2;
|
|
}
|
|
|
|
weekSollMinutes += daySollHours * 60;
|
|
|
|
// Berechne Ist-Zeit für diesen Tag
|
|
if (day.netWorkTime) {
|
|
const [h, m] = day.netWorkTime.split(':').map(Number);
|
|
weekIstMinutes += h * 60 + m;
|
|
}
|
|
});
|
|
|
|
// Überstunden = Ist - Soll
|
|
const overtimeMinutes = weekIstMinutes - weekSollMinutes;
|
|
const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60);
|
|
const overtimeMins = Math.abs(overtimeMinutes) % 60;
|
|
const overtimeSign = overtimeMinutes >= 0 ? '+' : '-';
|
|
const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`;
|
|
|
|
// Wochenarbeitszeit
|
|
const weekWorktimeHours = Math.floor(weekIstMinutes / 60);
|
|
const weekWorktimeMins = weekIstMinutes % 60;
|
|
const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`;
|
|
|
|
// Berechne "Offen für Woche"
|
|
// Dies ist: Wie lange muss ich HEUTE noch arbeiten, um das Wochenziel zu erreichen?
|
|
// = Wochensoll (gesamt) - Wochenist (abgeschlossene Tage + heute bisher)
|
|
let openForWeekFormatted = '—';
|
|
|
|
if (runningEntry) {
|
|
// Berechne Wochensoll und -ist
|
|
let weekSollMinutesTotal = 0;
|
|
let weekIstMinutesTotal = 0;
|
|
|
|
weekData.days.forEach(day => {
|
|
// Parse day.date als YYYY-MM-DD und vergleiche nur das Datum (ohne Zeit)
|
|
const dayDate = new Date(day.date + 'T00:00:00');
|
|
const dayDateOnly = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate());
|
|
|
|
// isToday basierend auf Datumsvergleich (ohne Uhrzeit)
|
|
const isToday = dayDateOnly.getTime() === todayDate.getTime();
|
|
|
|
// Zukünftige Tage überspringen
|
|
if (dayDateOnly > todayDate) {
|
|
return;
|
|
}
|
|
|
|
const dayOfWeek = dayDate.getDay();
|
|
|
|
// Nur Mo-Fr berücksichtigen
|
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
return;
|
|
}
|
|
|
|
// Prüfe auf Feiertag, Urlaub, Krankheit
|
|
if (day.sick || day.holiday) {
|
|
return;
|
|
}
|
|
|
|
if (day.vacation && !day.workBlocks?.length) {
|
|
return;
|
|
}
|
|
|
|
// Berechne Soll-Zeit für diesen Tag
|
|
const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek;
|
|
const dayTimewishes = timewishMap.get(timewishDay) || [];
|
|
|
|
const applicableTimewish = dayTimewishes.find(tw => {
|
|
const startDate = new Date(tw.start_date);
|
|
const endDate = tw.end_date ? new Date(tw.end_date) : null;
|
|
if (dayDate < startDate) return false;
|
|
if (endDate && dayDate > endDate) return false;
|
|
return true;
|
|
});
|
|
|
|
let daySollHours = userDailyHours; // Fallback: User's daily_hours
|
|
if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) {
|
|
daySollHours = applicableTimewish.hours;
|
|
}
|
|
|
|
if (day.vacation && day.vacation.halfDay) {
|
|
daySollHours = daySollHours / 2;
|
|
}
|
|
|
|
weekSollMinutesTotal += daySollHours * 60;
|
|
|
|
// Berechne Ist-Zeit für diesen Tag
|
|
let dayIstMinutes = 0;
|
|
|
|
if (isToday && currentlyWorked) {
|
|
// Für heute: Verwende die aktuell gearbeitete Zeit aus currentlyWorked
|
|
// NICHT day.netWorkTime verwenden, um Doppelzählung zu vermeiden!
|
|
const [h, m, s] = currentlyWorked.split(':').map(Number);
|
|
dayIstMinutes = h * 60 + m;
|
|
weekIstMinutesTotal += dayIstMinutes;
|
|
} else if (!isToday && day.netWorkTime) {
|
|
// Für vergangene Tage: Verwende netWorkTime
|
|
const [h, m] = day.netWorkTime.split(':').map(Number);
|
|
dayIstMinutes = h * 60 + m;
|
|
weekIstMinutesTotal += dayIstMinutes;
|
|
}
|
|
});
|
|
|
|
// Offen für Woche = Wochensoll - Wochenist (bisher)
|
|
const openForWeekMinutes = weekSollMinutesTotal - weekIstMinutesTotal;
|
|
|
|
if (openForWeekMinutes > 0) {
|
|
// Es muss noch gearbeitet werden, um das Wochenziel zu erreichen
|
|
const openWeekHours = Math.floor(openForWeekMinutes / 60);
|
|
const openWeekMins = openForWeekMinutes % 60;
|
|
openForWeekFormatted = `${openWeekHours}:${openWeekMins.toString().padStart(2, '0')}`;
|
|
} else if (openForWeekMinutes === 0) {
|
|
// Exakt das Wochenziel erreicht
|
|
openForWeekFormatted = 'Wochenziel erreicht';
|
|
} else {
|
|
// Negativ = Wochenziel bereits übertroffen
|
|
openForWeekFormatted = '—';
|
|
}
|
|
}
|
|
|
|
// Berechne "Bereinigtes Arbeitsende"
|
|
// Nur berechnen, wenn derzeit gearbeitet wird (NICHT in Pause!)
|
|
let adjustedEndTodayGeneral = '—';
|
|
let adjustedEndTodayWeek = '—';
|
|
|
|
// Hole den aktuellen Status
|
|
const currentState = await this.getCurrentState(uid);
|
|
|
|
// Arbeitsende berechnen, wenn derzeit gearbeitet wird
|
|
// Das ist der Fall bei: 'start work' (direkt nach Arbeitsbeginn) oder 'stop pause' (nach Pausenende)
|
|
const isCurrentlyWorking = runningEntry && (currentState === 'start work' || currentState === 'stop pause');
|
|
|
|
if (isCurrentlyWorking && open && open !== '—' && open !== 'Arbeitsende erreicht') {
|
|
// Parse "Offen" Zeit (Format: HH:MM:SS)
|
|
const openParts = open.split(':');
|
|
const openH = parseInt(openParts[0]);
|
|
const openM = parseInt(openParts[1]);
|
|
const openS = parseInt(openParts[2] || 0);
|
|
const openMinutes = openH * 60 + openM;
|
|
|
|
// Aktuelle Zeit
|
|
const currentTime = new Date();
|
|
|
|
// ===== GENERELL =====
|
|
// Verbleibende Zeit = Offen - Gesamt-Überstunden + fehlende Pausen
|
|
const generalOvertimeMinutes = totalOvertimeResult.minutes || 0;
|
|
const generalRemainingMinutes = openMinutes - generalOvertimeMinutes + missingBreakMinutes;
|
|
|
|
if (generalRemainingMinutes <= 0) {
|
|
adjustedEndTodayGeneral = 'Arbeitsende erreicht';
|
|
} else {
|
|
// Berechne Uhrzeit: jetzt + verbleibende Minuten
|
|
const endTime = new Date(currentTime.getTime() + generalRemainingMinutes * 60 * 1000);
|
|
const endH = endTime.getHours();
|
|
const endM = endTime.getMinutes();
|
|
adjustedEndTodayGeneral = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// ===== WOCHE =====
|
|
// Verbleibende Zeit = Offen - Wochen-Über/Unterstunden + fehlende Pausen
|
|
const weekOvertimeMinutes = overtimeMinutes; // Bereits berechnet
|
|
const weekRemainingMinutes = openMinutes - weekOvertimeMinutes + missingBreakMinutes;
|
|
|
|
if (weekRemainingMinutes <= 0) {
|
|
adjustedEndTodayWeek = 'Arbeitsende erreicht';
|
|
} else {
|
|
// Berechne Uhrzeit: jetzt + verbleibende Minuten
|
|
const endTime = new Date(currentTime.getTime() + weekRemainingMinutes * 60 * 1000);
|
|
const endH = endTime.getHours();
|
|
const endM = endTime.getMinutes();
|
|
adjustedEndTodayWeek = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`;
|
|
}
|
|
}
|
|
|
|
// Berechne Arbeitsfreie Stunden (Urlaub + Krankheit + Feiertage) bis heute
|
|
let nonWorkingMinutes = 0;
|
|
|
|
weekData.days.forEach(day => {
|
|
const dayDate = new Date(day.date);
|
|
|
|
// Nur Tage bis heute berücksichtigen
|
|
if (dayDate > todayDate) {
|
|
return;
|
|
}
|
|
|
|
// Bei laufendem Tag: überspringen
|
|
if (dayDate.getTime() === todayDate.getTime() && !includeTodayInOvertime) {
|
|
return;
|
|
}
|
|
|
|
const dayOfWeek = dayDate.getDay();
|
|
|
|
// Nur Mo-Fr berücksichtigen
|
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
return;
|
|
}
|
|
|
|
// Sammle arbeitsfreie Stunden
|
|
if (day.sick) {
|
|
nonWorkingMinutes += day.sick.hours * 60;
|
|
} else if (day.holiday) {
|
|
nonWorkingMinutes += day.holiday.hours * 60;
|
|
} else if (day.vacation) {
|
|
nonWorkingMinutes += day.vacation.hours * 60;
|
|
}
|
|
});
|
|
|
|
const nonWorkingHours = Math.floor(nonWorkingMinutes / 60);
|
|
const nonWorkingMins = nonWorkingMinutes % 60;
|
|
const nonWorkingHoursFormatted = nonWorkingMinutes > 0
|
|
? `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`
|
|
: null;
|
|
|
|
// TODO: Echte Berechnungen für die anderen Felder implementieren
|
|
|
|
return {
|
|
timestamp: new Date().toISOString(), // Zeitpunkt der Berechnung
|
|
currentlyWorked: currentlyWorked,
|
|
open: open,
|
|
requiredBreakMinutes: requiredBreakMinutes, // Gesetzlich erforderlich
|
|
alreadyTakenBreakMinutes: alreadyTakenBreakMinutes, // Bereits genommen
|
|
missingBreakMinutes: missingBreakMinutes, // Noch fehlend
|
|
regularEnd: null,
|
|
overtime: overtime, // Überstunden für die aktuelle Woche
|
|
totalOvertime: totalOvertimeResult.formatted, // Überstunden über den gesamten Zeitraum (timewish-basiert)
|
|
weekWorktime: weekWorktime,
|
|
nonWorkingHours: nonWorkingHoursFormatted,
|
|
openForWeek: openForWeekFormatted,
|
|
adjustedEndToday: 'Bereinigtes Arbeitsende (heute)', // Überschrift ohne Zeitwert
|
|
adjustedEndTodayGeneral: adjustedEndTodayGeneral,
|
|
adjustedEndTodayWeek: adjustedEndTodayWeek
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Berechnet die Gesamt-Überstunden über alle Wochen
|
|
* @param {number} userId - Benutzer-ID
|
|
* @param {Object|null} runningEntry - Laufender Eintrag (falls vorhanden)
|
|
* @returns {Promise<Object>} { minutes, formatted }
|
|
* @private
|
|
*/
|
|
/**
|
|
* Berechne Überstunden im alten Style (weekly_worktime)
|
|
* @private
|
|
*/
|
|
async _calculateTotalOvertimeOldStyle(userId, runningEntry) {
|
|
const { WeeklyWorktime } = database.getModels();
|
|
|
|
// Finde den ersten Worklog-Eintrag des Users
|
|
const firstEntry = await worklogRepository.findByUser(userId);
|
|
if (!firstEntry || firstEntry.length === 0) {
|
|
return { minutes: 0, formatted: '+0:00' };
|
|
}
|
|
|
|
// Hole alle WeeklyWorktime-Einträge für diesen User
|
|
const weeklyWorktimes = await WeeklyWorktime.findAll({
|
|
where: { user_id: userId },
|
|
order: [['starting_from', 'ASC']],
|
|
raw: true
|
|
});
|
|
|
|
if (weeklyWorktimes.length === 0) {
|
|
weeklyWorktimes.push({
|
|
weekly_work_time: 40,
|
|
starting_from: null,
|
|
ends_at: null
|
|
});
|
|
}
|
|
|
|
// Berechne Soll-Stunden für ein Datum basierend auf weekly_worktime
|
|
const getDailySollHours = (date) => {
|
|
const dateObj = new Date(date);
|
|
const dateString = dateObj.toISOString().split('T')[0];
|
|
|
|
// Finde passenden weekly_worktime Eintrag
|
|
let applicable = null;
|
|
for (const wt of weeklyWorktimes) {
|
|
const startFrom = wt.starting_from || '1900-01-01';
|
|
const endsAt = wt.ends_at || '9999-12-31';
|
|
|
|
if (dateString >= startFrom && dateString <= endsAt) {
|
|
applicable = wt;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!applicable) {
|
|
applicable = weeklyWorktimes[weeklyWorktimes.length - 1];
|
|
}
|
|
|
|
// Wochenstunden / 5 = Tagesstunden (Mo-Fr)
|
|
return applicable.weekly_work_time / 5;
|
|
};
|
|
|
|
const firstDate = new Date(firstEntry[0].tstamp + 'Z');
|
|
const firstMonday = new Date(firstDate);
|
|
const dayOfWeek = firstDate.getUTCDay();
|
|
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
firstMonday.setUTCDate(firstDate.getUTCDate() + daysToMonday);
|
|
firstMonday.setUTCHours(0, 0, 0, 0);
|
|
|
|
const now = new Date();
|
|
const todayMonday = new Date(now);
|
|
const todayDayOfWeek = now.getDay();
|
|
const todayDaysToMonday = todayDayOfWeek === 0 ? -6 : 1 - todayDayOfWeek;
|
|
todayMonday.setDate(now.getDate() + todayDaysToMonday);
|
|
todayMonday.setHours(0, 0, 0, 0);
|
|
|
|
const weeksDiff = Math.floor((todayMonday - firstMonday) / (7 * 24 * 60 * 60 * 1000));
|
|
|
|
let totalSollMinutes = 0;
|
|
let totalIstMinutes = 0;
|
|
|
|
const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const yesterdayDate = new Date(todayDate);
|
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
|
const endDate = yesterdayDate;
|
|
|
|
// Iteriere über alle Wochen
|
|
for (let weekOffset = -weeksDiff; weekOffset <= 0; weekOffset++) {
|
|
const weekData = await this.getWeekOverview(userId, weekOffset);
|
|
|
|
weekData.days.forEach(day => {
|
|
const dayDate = new Date(day.date);
|
|
|
|
if (dayDate > endDate) {
|
|
return;
|
|
}
|
|
|
|
const dayOfWeek = dayDate.getDay();
|
|
|
|
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
|
return;
|
|
}
|
|
|
|
const daySollHours = getDailySollHours(day.date);
|
|
|
|
if (day.holiday || day.sick) {
|
|
return;
|
|
}
|
|
|
|
if (day.vacation) {
|
|
const vacationHours = day.vacationHours || 0;
|
|
|
|
if (vacationHours === 4) {
|
|
// Halber Urlaubstag: Soll = Tagesstunden * 0.5
|
|
totalSollMinutes += (daySollHours * 0.5) * 60;
|
|
|
|
if (day.netWorkTime) {
|
|
const [h, m] = day.netWorkTime.split(':').map(Number);
|
|
totalIstMinutes += h * 60 + m;
|
|
}
|
|
} else {
|
|
// Ganzer Urlaubstag: überspringen
|
|
return;
|
|
}
|
|
} else {
|
|
// Normaler Arbeitstag
|
|
// WICHTIG für Alt-Style: Nur Tage zählen, an denen tatsächlich gearbeitet wurde!
|
|
if (day.netWorkTime) {
|
|
const [h, m] = day.netWorkTime.split(':').map(Number);
|
|
const istMinutes = h * 60 + m;
|
|
|
|
totalSollMinutes += daySollHours * 60;
|
|
totalIstMinutes += istMinutes;
|
|
}
|
|
// Tage OHNE Arbeit werden NICHT gezählt (kein Soll, kein Ist)
|
|
}
|
|
});
|
|
}
|
|
|
|
const overtimeMinutes = totalIstMinutes - totalSollMinutes;
|
|
const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60);
|
|
const overtimeMins = Math.abs(overtimeMinutes) % 60;
|
|
const overtimeSign = overtimeMinutes >= 0 ? '+' : '-';
|
|
const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`;
|
|
|
|
return { minutes: overtimeMinutes, formatted };
|
|
}
|
|
|
|
async _calculateTotalOvertime(userId, runningEntry) {
|
|
const { Timewish, Holiday, Vacation, Sick, Worklog, User } = database.getModels();
|
|
const sequelize = database.sequelize;
|
|
|
|
// Hole den Überstunden-Offset und daily_hours für diesen User
|
|
const user = await User.findByPk(userId, { attributes: ['overtime_offset_minutes', 'daily_hours'], raw: true });
|
|
const overtimeOffsetMinutes = user?.overtime_offset_minutes || 0;
|
|
const userDailyHours = user?.daily_hours || 8;
|
|
|
|
// Hole alle Timewishes für Mo-Fr
|
|
const allTimewishes = await Timewish.findAll({
|
|
where: {
|
|
user_id: userId,
|
|
day: {
|
|
[require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr
|
|
}
|
|
},
|
|
raw: true,
|
|
order: [['day', 'ASC'], ['start_date', 'ASC']]
|
|
});
|
|
|
|
// Erstelle Map: Tag -> Array von Timewishes
|
|
const timewishMap = new Map();
|
|
allTimewishes.forEach(tw => {
|
|
if (!timewishMap.has(tw.day)) {
|
|
timewishMap.set(tw.day, []);
|
|
}
|
|
timewishMap.get(tw.day).push(tw);
|
|
});
|
|
|
|
// Berechne Soll-Stunden für ein Datum basierend auf timewish
|
|
const getDailySollHours = (dateString, dayOfWeek) => {
|
|
// Konvertiere JS dayOfWeek (0=So, 1=Mo) zu DB dayOfWeek (1=Mo, 7=So)
|
|
const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek;
|
|
|
|
const dayTimewishes = timewishMap.get(timewishDay) || [];
|
|
|
|
// Finde den passenden Timewish für dieses Datum
|
|
const applicableTimewish = dayTimewishes.find(tw => {
|
|
const startDate = tw.start_date;
|
|
const endDate = tw.end_date || '9999-12-31';
|
|
|
|
return dateString >= startDate && dateString <= endDate;
|
|
});
|
|
|
|
if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) {
|
|
return parseFloat(applicableTimewish.hours);
|
|
}
|
|
|
|
// Fallback: User's daily_hours
|
|
return parseFloat(userDailyHours);
|
|
};
|
|
|
|
// Berechne Endedatum: gestern (wie alte MySQL-Funktion)
|
|
const now = new Date();
|
|
const yesterdayDate = new Date(now);
|
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
|
const endDateStr = yesterdayDate.toISOString().split('T')[0];
|
|
|
|
// Hole alle Arbeitstage mit Netto-Arbeitszeit aus der DB
|
|
// WICHTIG: Berücksichtigt timefix-Korrekturen!
|
|
// Dies ist VIEL schneller als 253x getWeekOverview aufzurufen!
|
|
const query = `
|
|
SELECT
|
|
DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) as work_date,
|
|
DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) as day_of_week,
|
|
SUM(
|
|
TIMESTAMPDIFF(SECOND,
|
|
COALESCE(w1_fix.fix_date_time, w1.tstamp),
|
|
COALESCE(w2_fix.fix_date_time, w2.tstamp)
|
|
) - IFNULL(pauses.pause_seconds, 0)
|
|
) / 3600 as net_hours
|
|
FROM worklog w1
|
|
LEFT JOIN timefix w1_fix ON w1_fix.worklog_id = w1.id
|
|
JOIN worklog w2 ON w2.relatedTo_id = w1.id AND w2.state = 'stop work'
|
|
LEFT JOIN timefix w2_fix ON w2_fix.worklog_id = w2.id
|
|
LEFT JOIN (
|
|
SELECT
|
|
p1.relatedTo_id as work_id,
|
|
SUM(
|
|
TIMESTAMPDIFF(SECOND,
|
|
COALESCE(p1_fix.fix_date_time, p1.tstamp),
|
|
COALESCE(p2_fix.fix_date_time, p2.tstamp)
|
|
)
|
|
) as pause_seconds
|
|
FROM worklog p1
|
|
LEFT JOIN timefix p1_fix ON p1_fix.worklog_id = p1.id
|
|
JOIN worklog p2 ON p2.relatedTo_id = p1.id AND p2.state = 'stop pause'
|
|
LEFT JOIN timefix p2_fix ON p2_fix.worklog_id = p2.id
|
|
WHERE p1.state = 'start pause'
|
|
GROUP BY p1.relatedTo_id
|
|
) pauses ON pauses.work_id = w1.id
|
|
WHERE w1.state = 'start work'
|
|
AND w1.user_id = ?
|
|
AND DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) <= ?
|
|
AND DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) BETWEEN 2 AND 6
|
|
GROUP BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)), DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)))
|
|
ORDER BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))
|
|
`;
|
|
|
|
const workDays = await sequelize.query(query, {
|
|
replacements: [userId, endDateStr],
|
|
type: sequelize.QueryTypes.SELECT
|
|
});
|
|
|
|
// Hole User-Bundesland
|
|
const { State: StateModel } = database.getModels();
|
|
const userForState = await User.findByPk(userId, {
|
|
attributes: ['state_id'],
|
|
raw: true
|
|
});
|
|
const userStateId = userForState?.state_id;
|
|
|
|
// Hole Feiertage mit States
|
|
const holidays = await Holiday.findAll({
|
|
where: {
|
|
date: {
|
|
[require('sequelize').Op.lte]: endDateStr
|
|
}
|
|
},
|
|
include: [
|
|
{
|
|
model: StateModel,
|
|
as: 'states',
|
|
attributes: ['id'],
|
|
through: { attributes: [] },
|
|
required: false
|
|
}
|
|
]
|
|
});
|
|
|
|
// Filtere nur Feiertage die für den User gelten
|
|
const holidaySet = new Set();
|
|
holidays.forEach(h => {
|
|
const holidayStates = h.states || [];
|
|
const isFederal = holidayStates.length === 0;
|
|
const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId);
|
|
|
|
if (appliesToUser) {
|
|
holidaySet.add(h.date);
|
|
}
|
|
});
|
|
|
|
// Hole Krankheitstage (expandiert)
|
|
const sickDaysRaw = await Sick.findAll({
|
|
where: {
|
|
user_id: userId,
|
|
first_day: {
|
|
[require('sequelize').Op.lte]: endDateStr
|
|
}
|
|
},
|
|
raw: true
|
|
});
|
|
|
|
const sickSet = new Set();
|
|
sickDaysRaw.forEach(sick => {
|
|
const start = new Date(sick.first_day);
|
|
const end = new Date(sick.last_day);
|
|
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const dow = d.getDay();
|
|
if (dow >= 1 && dow <= 5) { // Mo-Fr
|
|
sickSet.add(d.toISOString().split('T')[0]);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Hole Urlaubstage (expandiert)
|
|
const vacationDaysRaw = await Vacation.findAll({
|
|
where: {
|
|
user_id: userId,
|
|
first_day: {
|
|
[require('sequelize').Op.lte]: endDateStr
|
|
}
|
|
},
|
|
raw: true
|
|
});
|
|
|
|
const vacationMap = new Map(); // date -> hours (4 oder 8)
|
|
vacationDaysRaw.forEach(vac => {
|
|
const start = new Date(vac.first_day);
|
|
const end = new Date(vac.last_day);
|
|
const hours = vac.vacation_type === 1 ? 4 : 8;
|
|
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const dow = d.getDay();
|
|
if (dow >= 1 && dow <= 5) { // Mo-Fr
|
|
vacationMap.set(d.toISOString().split('T')[0], hours);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Erstelle Map: work_date -> net_hours
|
|
const workDaysMap = new Map();
|
|
let totalIstHoursFromDB = 0;
|
|
workDays.forEach(wd => {
|
|
const netHours = parseFloat(wd.net_hours);
|
|
workDaysMap.set(wd.work_date, netHours);
|
|
totalIstHoursFromDB += netHours;
|
|
});
|
|
|
|
// Berechne Soll-Stunden für alle Arbeitstage
|
|
let totalSollMinutes = 0;
|
|
let processedDays = 0;
|
|
let sickDaysCount = 0;
|
|
let holidayDaysCount = 0;
|
|
let vacationFullDays = 0;
|
|
let vacationHalfDays = 0;
|
|
let workedDays = 0;
|
|
let notWorkedDays = 0;
|
|
|
|
// Berechne Soll-Zeit für jeden Tag in workDaysMap (= Tage mit tatsächlicher Arbeit)
|
|
workDaysMap.forEach((istHours, dateStr) => {
|
|
const d = new Date(dateStr);
|
|
const dow = d.getDay();
|
|
|
|
// Prüfe auf Feiertag, Krankheit, Urlaub
|
|
if (holidaySet.has(dateStr)) {
|
|
holidayDaysCount++;
|
|
return;
|
|
}
|
|
|
|
if (sickSet.has(dateStr)) {
|
|
sickDaysCount++;
|
|
return;
|
|
}
|
|
|
|
const vacationHours = vacationMap.get(dateStr);
|
|
if (vacationHours) {
|
|
if (vacationHours === 4) {
|
|
// Halber Urlaubstag
|
|
vacationHalfDays++;
|
|
const sollHours = getDailySollHours(dateStr, dow);
|
|
totalSollMinutes += (sollHours - 4) * 60;
|
|
workedDays++;
|
|
} else {
|
|
// Ganzer Urlaubstag
|
|
vacationFullDays++;
|
|
}
|
|
} else {
|
|
// Normaler Arbeitstag
|
|
const sollHours = getDailySollHours(dateStr, dow);
|
|
totalSollMinutes += sollHours * 60;
|
|
workedDays++;
|
|
processedDays++;
|
|
}
|
|
});
|
|
|
|
// Verwende die Ist-Stunden direkt aus der DB-Query (korrekt!)
|
|
const totalIstMinutes = totalIstHoursFromDB * 60;
|
|
|
|
// Berechne Überstunden ohne Offset
|
|
const overtimeMinutesRaw = Math.round(totalIstMinutes - totalSollMinutes);
|
|
|
|
// Addiere den Offset
|
|
const overtimeMinutes = overtimeMinutesRaw + overtimeOffsetMinutes;
|
|
|
|
const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60);
|
|
const overtimeMins = Math.abs(overtimeMinutes) % 60;
|
|
const overtimeSign = overtimeMinutes >= 0 ? '+' : '-';
|
|
const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`;
|
|
|
|
return { minutes: overtimeMinutes, formatted };
|
|
}
|
|
|
|
/**
|
|
* Wochenübersicht für einen Benutzer abrufen
|
|
* @param {number} userId - Benutzer-ID
|
|
* @param {number} weekOffset - Wochen-Offset (0 = aktuelle Woche, -1 = letzte Woche, etc.)
|
|
* @returns {Promise<Object>} Wochenübersicht mit Tagesdaten
|
|
*/
|
|
async getWeekOverview(userId, weekOffset = 0) {
|
|
const uid = userId || this.defaultUserId;
|
|
|
|
// Berechne Start und Ende der gewünschten Woche (Montag bis Sonntag)
|
|
// Verwende lokales Datum, nicht UTC
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = now.getMonth();
|
|
const day = now.getDate();
|
|
const currentDay = now.getDay(); // 0 = Sonntag, 1 = Montag, ..., 6 = Samstag
|
|
|
|
// Offset zu Montag berechnen
|
|
// Montag ist Tag 1, wir wollen zum Montag der aktuellen Woche
|
|
// Wenn Sonntag (0), dann ist das Ende der Woche, also -6 Tage zum Montag
|
|
// Wenn Montag (1), dann 0 Tage
|
|
// Wenn Dienstag (2), dann -1 Tag, etc.
|
|
const daysToMonday = currentDay === 0 ? -6 : 1 - currentDay;
|
|
|
|
// Berechne Montag der Woche
|
|
const weekStart = new Date(year, month, day + daysToMonday + (weekOffset * 7));
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
|
|
// Berechne Sonntag der Woche
|
|
const weekEnd = new Date(year, month, day + daysToMonday + (weekOffset * 7) + 6);
|
|
weekEnd.setHours(23, 59, 59, 999);
|
|
|
|
// Hole alle Worklog-Einträge für diese Woche
|
|
const pairs = await worklogRepository.findPairsByUserInDateRange(uid, weekStart, weekEnd);
|
|
|
|
// Hole auch alle einzelnen Einträge für Pausen
|
|
const allEntries = await worklogRepository.findByDateRange(uid, weekStart, weekEnd);
|
|
|
|
// Hole Timefix-Einträge für alle Worklog-IDs
|
|
const allWorklogIds = [...new Set([
|
|
...pairs.map(p => p.id),
|
|
...allEntries.map(e => e.id)
|
|
])];
|
|
const timefixMap = await worklogRepository.getTimefixesByWorklogIds(allWorklogIds);
|
|
|
|
// Hole Vacation-Einträge für diese Woche
|
|
const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd);
|
|
|
|
// Erstelle Map von Datum zu Vacation
|
|
const vacationMap = new Map();
|
|
vacations.forEach(vac => {
|
|
const vacDate = new Date(vac.date);
|
|
const vacKey = `${vacDate.getUTCFullYear()}-${(vacDate.getUTCMonth()+1).toString().padStart(2,'0')}-${vacDate.getUTCDate().toString().padStart(2,'0')}`;
|
|
vacationMap.set(vacKey, vac);
|
|
});
|
|
|
|
// Hole Sick-Einträge für diese Woche
|
|
const sickDays = await worklogRepository.getSickByUserInDateRange(uid, weekStart, weekEnd);
|
|
|
|
// Erstelle Map von Datum zu Sick
|
|
const sickMap = new Map();
|
|
sickDays.forEach(sick => {
|
|
const sickDate = new Date(sick.date);
|
|
const sickKey = `${sickDate.getUTCFullYear()}-${(sickDate.getUTCMonth()+1).toString().padStart(2,'0')}-${sickDate.getUTCDate().toString().padStart(2,'0')}`;
|
|
sickMap.set(sickKey, sick);
|
|
});
|
|
|
|
// Hole Feiertage für diese Woche (nur die für das User-Bundesland gelten)
|
|
const holidays = await worklogRepository.getHolidaysInDateRange(weekStart, weekEnd, uid);
|
|
|
|
// Erstelle Map von Datum zu Holiday
|
|
const holidayMap = new Map();
|
|
holidays.forEach(holiday => {
|
|
const holidayDate = new Date(holiday.date);
|
|
const holidayKey = `${holidayDate.getUTCFullYear()}-${(holidayDate.getUTCMonth()+1).toString().padStart(2,'0')}-${holidayDate.getUTCDate().toString().padStart(2,'0')}`;
|
|
holidayMap.set(holidayKey, {
|
|
hours: holiday.hours || 8,
|
|
description: holiday.description
|
|
});
|
|
});
|
|
|
|
// Gruppiere Einträge nach Tagen
|
|
const dayData = {};
|
|
|
|
// Initialisiere alle Wochentage
|
|
const todayKey = `${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')}`;
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
const currentDay = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i);
|
|
const dayKey = `${currentDay.getFullYear()}-${(currentDay.getMonth()+1).toString().padStart(2,'0')}-${currentDay.getDate().toString().padStart(2,'0')}`;
|
|
|
|
dayData[dayKey] = {
|
|
date: dayKey,
|
|
name: this._getDayName(currentDay.getDay()),
|
|
workBlocks: [], // Array für mehrere Arbeitszeitblöcke
|
|
workTime: null, // Für Kompatibilität
|
|
pauses: [],
|
|
totalWorkTime: null,
|
|
pauseTimes: [],
|
|
netWorkTime: null,
|
|
status: '',
|
|
statusText: '',
|
|
isToday: dayKey === todayKey
|
|
};
|
|
}
|
|
|
|
// Verarbeite gefundene Einträge - SAMMLE alle Arbeitsblöcke
|
|
(Array.isArray(pairs) ? pairs : []).forEach(pair => {
|
|
if (!pair.start_time) return;
|
|
|
|
const startDate = new Date(pair.start_time);
|
|
const dayKey = `${startDate.getFullYear()}-${(startDate.getMonth()+1).toString().padStart(2,'0')}-${startDate.getDate().toString().padStart(2,'0')}`;
|
|
|
|
if (!dayData[dayKey]) return;
|
|
|
|
const day = dayData[dayKey];
|
|
|
|
// Erstelle einen Arbeitsblock für diesen Pair
|
|
const workBlock = {};
|
|
|
|
if (pair.end_time) {
|
|
// Abgeschlossener Arbeitstag
|
|
// WICHTIG: DB speichert lokale Zeit als UTC
|
|
// Wir müssen die UTC-Komponenten direkt als lokale Zeit interpretieren
|
|
let workStartUTC = new Date(pair.start_time);
|
|
let workEndUTC = new Date(pair.end_time);
|
|
|
|
// Prüfe auf Timefix-Korrekturen
|
|
const startFix = timefixMap.get(pair.id)?.find(f => f.fix_type === 'start work');
|
|
const endFixEntry = allEntries.find(e => {
|
|
const action = (typeof e.state === 'string' ? JSON.parse(e.state) : e.state)?.action || e.state;
|
|
return action === 'stop work' && e.relatedTo_id === pair.id;
|
|
});
|
|
const endFix = endFixEntry ? timefixMap.get(endFixEntry.id)?.find(f => f.fix_type === 'stop work') : null;
|
|
|
|
// Verwende korrigierte Zeiten falls vorhanden
|
|
const originalStartTime = workStartUTC;
|
|
const originalEndTime = workEndUTC;
|
|
|
|
if (startFix) {
|
|
// DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation
|
|
const fixTime = typeof startFix.fix_date_time === 'string'
|
|
? startFix.fix_date_time.replace(' ', 'T') + 'Z'
|
|
: startFix.fix_date_time;
|
|
workStartUTC = new Date(fixTime);
|
|
}
|
|
if (endFix) {
|
|
// DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation
|
|
const fixTime = typeof endFix.fix_date_time === 'string'
|
|
? endFix.fix_date_time.replace(' ', 'T') + 'Z'
|
|
: endFix.fix_date_time;
|
|
workEndUTC = new Date(fixTime);
|
|
}
|
|
|
|
// Parse Zeit-String (YYYY-MM-DD HH:MM:SS) direkt
|
|
// tstamp ist jetzt ein String durch typeCast
|
|
const parseTimeString = (timeStr) => {
|
|
if (typeof timeStr === 'string') {
|
|
const parts = timeStr.split(' ')[1]; // "HH:MM:SS"
|
|
const [h, m] = parts.split(':').map(Number);
|
|
return { hours: h, minutes: m };
|
|
}
|
|
// Fallback für Date-Objekte (sollte nicht mehr vorkommen)
|
|
return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() };
|
|
};
|
|
|
|
// Verwende korrigierte Zeiten für die Anzeige
|
|
const start = parseTimeString(startFix ? workStartUTC : pair.start_time);
|
|
const end = parseTimeString(endFix ? workEndUTC : pair.end_time);
|
|
const startHours = start.hours;
|
|
const startMinutes = start.minutes;
|
|
const endHours = end.hours;
|
|
const endMinutes = end.minutes;
|
|
|
|
// Original-Zeiten für Tooltip
|
|
const origStart = parseTimeString(originalStartTime);
|
|
const origEnd = parseTimeString(originalEndTime);
|
|
const originalStartHours = origStart.hours;
|
|
const originalStartMinutes = origStart.minutes;
|
|
const originalEndHours = origEnd.hours;
|
|
const originalEndMinutes = origEnd.minutes;
|
|
|
|
// Berechne Arbeitszeit (Dauer bleibt gleich, da beide gleich verschoben sind)
|
|
const workStartMs = typeof pair.start_time === 'string' ? new Date(pair.start_time).getTime() : workStartUTC.getTime();
|
|
const workEndMs = typeof pair.end_time === 'string' ? new Date(pair.end_time).getTime() : workEndUTC.getTime();
|
|
const workDuration = workEndMs - workStartMs;
|
|
const workHours = Math.floor(workDuration / (1000 * 60 * 60));
|
|
const workMinutes = Math.floor((workDuration % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - ${endHours.toString().padStart(2,'0')}:${endMinutes.toString().padStart(2,'0')}`;
|
|
workBlock.totalWorkTime = `${workHours}:${workMinutes.toString().padStart(2, '0')}`;
|
|
workBlock.workTimeFixed = !!(startFix || endFix);
|
|
|
|
if (startFix || endFix) {
|
|
workBlock.workTimeOriginal = `${originalStartHours.toString().padStart(2,'0')}:${originalStartMinutes.toString().padStart(2,'0')} - ${originalEndHours.toString().padStart(2,'0')}:${originalEndMinutes.toString().padStart(2,'0')}`;
|
|
}
|
|
|
|
// Berechne Pausen NUR für diesen spezifischen Arbeitsblock
|
|
// Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id)
|
|
const blockEntries = allEntries.filter(e => {
|
|
// Parse state if it's a JSON string
|
|
let state = e.state;
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (err) {
|
|
// state bleibt als String
|
|
}
|
|
}
|
|
|
|
const action = state?.action || state;
|
|
|
|
// Einträge gehören zum Block wenn:
|
|
// 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt)
|
|
// 2. Oder sie stop work sind mit relatedTo_id = pair.id
|
|
|
|
if (action === 'start pause' && e.relatedTo_id === pair.id) {
|
|
return true; // Pause gehört zu diesem Arbeitsblock
|
|
}
|
|
|
|
if (action === 'stop pause') {
|
|
// Finde das zugehörige start pause
|
|
const startPause = allEntries.find(sp => {
|
|
const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state;
|
|
const spAction = spState?.action || spState;
|
|
return spAction === 'start pause' && sp.id === e.relatedTo_id;
|
|
});
|
|
|
|
if (startPause && startPause.relatedTo_id === pair.id) {
|
|
return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// Finde Pausen-Paare NUR für diesen Block
|
|
const pausePairs = [];
|
|
const pauseStarts = {};
|
|
|
|
blockEntries.forEach(entry => {
|
|
// Parse state if it's a JSON string
|
|
let state = entry.state;
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (e) {
|
|
// state bleibt als String
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Verwende die tatsächlichen Zeitwerte
|
|
let pStartTime = startPause.tstamp;
|
|
let pEndTime = entry.tstamp;
|
|
|
|
// Prüfe auf Timefix-Korrekturen
|
|
const pauseStartFix = timefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause');
|
|
const pauseEndFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause');
|
|
|
|
const originalPStartTime = pStartTime;
|
|
const originalPEndTime = pEndTime;
|
|
|
|
if (pauseStartFix) {
|
|
// DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation
|
|
pStartTime = typeof pauseStartFix.fix_date_time === 'string'
|
|
? pauseStartFix.fix_date_time.replace(' ', 'T') + 'Z'
|
|
: pauseStartFix.fix_date_time;
|
|
}
|
|
if (pauseEndFix) {
|
|
// DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation
|
|
pEndTime = typeof pauseEndFix.fix_date_time === 'string'
|
|
? pauseEndFix.fix_date_time.replace(' ', 'T') + 'Z'
|
|
: pauseEndFix.fix_date_time;
|
|
}
|
|
|
|
// Berechne Dauer (aus Strings)
|
|
const pStartMs = new Date(pStartTime).getTime();
|
|
const pEndMs = new Date(pEndTime).getTime();
|
|
const duration = pEndMs - pStartMs;
|
|
|
|
// Parse Pausen-Zeiten
|
|
const pStart = parseTimeString(pStartTime);
|
|
const pEnd = parseTimeString(pEndTime);
|
|
const pStartHours = pStart.hours;
|
|
const pStartMinutes = pStart.minutes;
|
|
const pEndHours = pEnd.hours;
|
|
const pEndMinutes = pEnd.minutes;
|
|
|
|
const pauseData = {
|
|
start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`,
|
|
end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`,
|
|
duration: duration,
|
|
fixed: !!(pauseStartFix || pauseEndFix)
|
|
};
|
|
|
|
if (pauseStartFix || pauseEndFix) {
|
|
const origStart = parseTimeString(originalPStartTime);
|
|
const origEnd = parseTimeString(originalPEndTime);
|
|
pauseData.original = `${origStart.hours.toString().padStart(2,'0')}:${origStart.minutes.toString().padStart(2,'0')} - ${origEnd.hours.toString().padStart(2,'0')}:${origEnd.minutes.toString().padStart(2,'0')}`;
|
|
}
|
|
|
|
pausePairs.push(pauseData);
|
|
|
|
delete pauseStarts[entry.relatedTo_id];
|
|
}
|
|
}
|
|
});
|
|
|
|
// Berechne Pausenzeiten mit Korrektur-Informationen
|
|
workBlock.pauses = pausePairs.map(p => ({
|
|
time: `${p.start} - ${p.end}`,
|
|
fixed: p.fixed,
|
|
original: p.original
|
|
}));
|
|
|
|
workBlock.pauseTimes = pausePairs.map(p => {
|
|
const pauseMinutes = Math.floor(p.duration / (1000 * 60));
|
|
const pH = Math.floor(pauseMinutes / 60);
|
|
const pM = pauseMinutes % 60;
|
|
return {
|
|
time: `${pH}:${pM.toString().padStart(2, '0')}`,
|
|
fixed: p.fixed,
|
|
original: p.original
|
|
};
|
|
});
|
|
|
|
const totalPauseDuration = pausePairs.reduce((sum, p) => sum + p.duration, 0);
|
|
const netWorkDuration = workDuration - totalPauseDuration;
|
|
const netHours = Math.floor(netWorkDuration / (1000 * 60 * 60));
|
|
const netMinutes = Math.floor((netWorkDuration % (1000 * 60 * 60)) / (1000 * 60));
|
|
|
|
workBlock.netWorkTime = `${netHours}:${netMinutes.toString().padStart(2, '0')}`;
|
|
workBlock.completed = true;
|
|
|
|
// Füge Arbeitsblock zum Tag hinzu
|
|
day.workBlocks.push(workBlock);
|
|
|
|
// Für Kompatibilität: Setze auch die alten Felder (letzter Block)
|
|
day.workTime = workBlock.workTime;
|
|
day.workTimeFixed = workBlock.workTimeFixed;
|
|
day.workTimeOriginal = workBlock.workTimeOriginal;
|
|
day.pauses = workBlock.pauses;
|
|
day.totalWorkTime = workBlock.totalWorkTime;
|
|
day.pauseTimes = workBlock.pauseTimes;
|
|
day.netWorkTime = workBlock.netWorkTime;
|
|
day.status = 'complete';
|
|
day.statusText = 'Abgeschlossen';
|
|
} else {
|
|
// Laufender Arbeitstag
|
|
|
|
// Prüfe auf Timefix-Korrektur für Start
|
|
const startFix = timefixMap.get(pair.id)?.find(f => f.fix_type === 'start work');
|
|
|
|
const parseTimeString = (timeStr) => {
|
|
if (typeof timeStr === 'string') {
|
|
const parts = timeStr.split(' ')[1]; // "HH:MM:SS"
|
|
const [h, m] = parts.split(':').map(Number);
|
|
return { hours: h, minutes: m };
|
|
}
|
|
// Fallback für Date-Objekte (sollte nicht mehr vorkommen)
|
|
return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() };
|
|
};
|
|
|
|
// Verwende korrigierte Zeit falls vorhanden
|
|
let displayStartTime = pair.start_time;
|
|
if (startFix) {
|
|
// DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation
|
|
const fixTime = typeof startFix.fix_date_time === 'string'
|
|
? startFix.fix_date_time.replace(' ', 'T') + 'Z'
|
|
: startFix.fix_date_time;
|
|
displayStartTime = new Date(fixTime);
|
|
}
|
|
|
|
const start = parseTimeString(displayStartTime);
|
|
const startHours = start.hours;
|
|
const startMinutes = start.minutes;
|
|
|
|
workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - jetzt`;
|
|
workBlock.workTimeFixed = !!startFix;
|
|
workBlock.completed = false;
|
|
|
|
if (startFix) {
|
|
// Original-Zeit für Tooltip
|
|
const origStart = parseTimeString(pair.start_time);
|
|
workBlock.workTimeOriginal = `${origStart.hours.toString().padStart(2,'0')}:${origStart.minutes.toString().padStart(2,'0')} - jetzt`;
|
|
}
|
|
|
|
// Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock
|
|
const blockEntries = allEntries.filter(e => {
|
|
// Parse state if it's a JSON string
|
|
let state = e.state;
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (err) {
|
|
// state bleibt als String
|
|
}
|
|
}
|
|
|
|
const action = state?.action || state;
|
|
|
|
// Einträge gehören zum Block wenn:
|
|
// 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt)
|
|
|
|
if (action === 'start pause' && e.relatedTo_id === pair.id) {
|
|
return true; // Pause gehört zu diesem Arbeitsblock
|
|
}
|
|
|
|
if (action === 'stop pause') {
|
|
// Finde das zugehörige start pause
|
|
const startPause = allEntries.find(sp => {
|
|
const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state;
|
|
const spAction = spState?.action || spState;
|
|
return spAction === 'start pause' && sp.id === e.relatedTo_id;
|
|
});
|
|
|
|
if (startPause && startPause.relatedTo_id === pair.id) {
|
|
return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört
|
|
}
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// Finde laufende Pause
|
|
const pauseStarts = {};
|
|
const pausePairs = [];
|
|
|
|
blockEntries.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) {
|
|
// Abgeschlossene Pause
|
|
const pStartUTC = new Date(startPause.tstamp);
|
|
const pEndUTC = new Date(entry.tstamp);
|
|
const duration = pEndUTC.getTime() - pStartUTC.getTime();
|
|
|
|
const pStart = parseTimeString(startPause.tstamp);
|
|
const pEnd = parseTimeString(entry.tstamp);
|
|
const pStartHours = pStart.hours;
|
|
const pStartMinutes = pStart.minutes;
|
|
const pEndHours = pEnd.hours;
|
|
const pEndMinutes = pEnd.minutes;
|
|
|
|
pausePairs.push({
|
|
start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`,
|
|
end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`,
|
|
duration: duration,
|
|
fixed: false
|
|
});
|
|
|
|
delete pauseStarts[entry.relatedTo_id]; // Lösche das START pause, nicht das STOP pause
|
|
}
|
|
}
|
|
});
|
|
|
|
// Füge laufende Pausen hinzu
|
|
Object.values(pauseStarts).forEach(startPause => {
|
|
const parseTimeString = (timeStr) => {
|
|
if (typeof timeStr === 'string') {
|
|
const parts = timeStr.split(' ')[1];
|
|
const [h, m] = parts.split(':').map(Number);
|
|
return { hours: h, minutes: m };
|
|
}
|
|
// Fallback für Date-Objekte (sollte nicht mehr vorkommen)
|
|
return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() };
|
|
};
|
|
|
|
const pStart = parseTimeString(startPause.tstamp);
|
|
const pStartHours = pStart.hours;
|
|
const pStartMinutes = pStart.minutes;
|
|
|
|
pausePairs.push({
|
|
start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`,
|
|
end: 'läuft',
|
|
duration: null,
|
|
fixed: false,
|
|
running: true
|
|
});
|
|
});
|
|
|
|
// Setze Pausen-Daten für diesen Block
|
|
workBlock.pauses = pausePairs.map(p => ({
|
|
time: p.end === 'läuft' ? `${p.start} - jetzt` : `${p.start} - ${p.end}`,
|
|
fixed: p.fixed,
|
|
running: p.running
|
|
}));
|
|
|
|
workBlock.pauseTimes = pausePairs.filter(p => p.duration).map(p => {
|
|
const pauseMinutes = Math.floor(p.duration / (1000 * 60));
|
|
const pH = Math.floor(pauseMinutes / 60);
|
|
const pM = pauseMinutes % 60;
|
|
return {
|
|
time: `${pH}:${pM.toString().padStart(2, '0')}`,
|
|
fixed: false
|
|
};
|
|
});
|
|
|
|
// Füge laufenden Block zum Tag hinzu
|
|
day.workBlocks.push(workBlock);
|
|
|
|
// Für Kompatibilität: Setze auch die alten Felder (letzter Block)
|
|
day.workTime = workBlock.workTime;
|
|
day.pauses = workBlock.pauses;
|
|
day.pauseTimes = workBlock.pauseTimes;
|
|
day.status = 'running';
|
|
day.statusText = 'Läuft';
|
|
}
|
|
});
|
|
|
|
// Nach dem Sammeln aller Blöcke: Berechne Gesamtwerte pro Tag
|
|
Object.values(dayData).forEach(day => {
|
|
if (day.workBlocks.length === 0) return;
|
|
|
|
// Berechne Gesamt-Arbeitszeit und -Nettozeit
|
|
let totalWorkMinutes = 0;
|
|
let totalNetMinutes = 0;
|
|
|
|
day.workBlocks.forEach(block => {
|
|
if (block.totalWorkTime) {
|
|
const [h, m] = block.totalWorkTime.split(':').map(Number);
|
|
totalWorkMinutes += h * 60 + m;
|
|
}
|
|
|
|
if (block.netWorkTime) {
|
|
const [h, m] = block.netWorkTime.split(':').map(Number);
|
|
totalNetMinutes += h * 60 + m;
|
|
}
|
|
});
|
|
|
|
// Setze Gesamtwerte (für Wochensumme)
|
|
if (totalWorkMinutes > 0) {
|
|
const tH = Math.floor(totalWorkMinutes / 60);
|
|
const tM = totalWorkMinutes % 60;
|
|
day.totalWorkTime = `${tH}:${tM.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
if (totalNetMinutes > 0) {
|
|
const nH = Math.floor(totalNetMinutes / 60);
|
|
const nM = totalNetMinutes % 60;
|
|
day.netWorkTime = `${nH}:${nM.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// NICHT die Pausen auf Day-Level setzen - sie sind bereits in den Blöcken!
|
|
// Das Frontend zeigt die Blöcke einzeln an, nicht die Tag-Level-Pausen.
|
|
|
|
// Status basierend auf Blöcken
|
|
const hasRunning = day.workBlocks.some(b => !b.completed);
|
|
const hasCompleted = day.workBlocks.some(b => b.completed);
|
|
|
|
if (hasRunning) {
|
|
day.status = 'running';
|
|
day.statusText = 'Läuft';
|
|
} else if (hasCompleted) {
|
|
day.status = 'complete';
|
|
day.statusText = 'Abgeschlossen';
|
|
}
|
|
});
|
|
|
|
// Markiere Wochenenden, integriere Urlaub, Krankheit und Feiertage
|
|
Object.values(dayData).forEach(day => {
|
|
const dayOfWeek = new Date(day.date).getDay();
|
|
|
|
// Sammle alle Ereignisse für diesen Tag
|
|
const events = [];
|
|
|
|
// Prüfe auf Krankheit
|
|
const sick = sickMap.get(day.date);
|
|
if (sick) {
|
|
day.sick = {
|
|
hours: 8,
|
|
type: sick.sick_type
|
|
};
|
|
|
|
const sickLabels = {
|
|
'self': 'Krank',
|
|
'child': 'Kind krank',
|
|
'parents': 'Eltern krank',
|
|
'partner': 'Partner krank'
|
|
};
|
|
events.push(sickLabels[sick.sick_type] || 'Krank');
|
|
|
|
// Bei Krankheit: Lösche geloggte Arbeitszeit
|
|
day.workTime = null;
|
|
day.pauses = [];
|
|
day.totalWorkTime = null;
|
|
day.pauseTimes = [];
|
|
day.netWorkTime = null;
|
|
}
|
|
|
|
// Prüfe auf Feiertag
|
|
const holiday = holidayMap.get(day.date);
|
|
if (holiday) {
|
|
day.holiday = {
|
|
hours: holiday.hours,
|
|
description: holiday.description
|
|
};
|
|
events.push(holiday.description || 'Feiertag');
|
|
}
|
|
|
|
// Prüfe auf Urlaub
|
|
const vacation = vacationMap.get(day.date);
|
|
if (vacation) {
|
|
const isHalfDay = vacation.half_day === 1;
|
|
const vacationHours = isHalfDay ? 4 : 8;
|
|
|
|
day.vacation = {
|
|
hours: vacationHours,
|
|
halfDay: isHalfDay
|
|
};
|
|
events.push(isHalfDay ? 'Urlaub (halber Tag)' : 'Urlaub');
|
|
}
|
|
|
|
// Prüfe auf Wochenende
|
|
const isWeekend = (dayOfWeek === 0 || dayOfWeek === 6);
|
|
if (isWeekend) {
|
|
events.push('Wochenende');
|
|
}
|
|
|
|
// Setze Status basierend auf gesammelten Ereignissen
|
|
if (events.length > 0) {
|
|
if (day.workTime) {
|
|
// Ereignis(se) + Arbeit
|
|
day.statusText = events.join(' + ') + ' + Arbeit';
|
|
day.status = 'mixed-work';
|
|
} else {
|
|
// Nur Ereignis(se), keine Arbeit
|
|
day.statusText = events.join(' + ');
|
|
// Status basierend auf primärem Ereignis
|
|
if (sick) {
|
|
day.status = 'sick';
|
|
} else if (holiday) {
|
|
day.status = 'holiday';
|
|
} else if (vacation) {
|
|
day.status = vacation.halfDay ? 'vacation-half' : 'vacation-full';
|
|
} else if (isWeekend) {
|
|
day.status = 'weekend';
|
|
}
|
|
}
|
|
} else if (isWeekend) {
|
|
// Nur Wochenende, keine anderen Events
|
|
day.status = 'weekend';
|
|
day.statusText = 'Wochenende';
|
|
}
|
|
});
|
|
|
|
// Berechne Wochensumme (Arbeit + Urlaub + Krankheit + Feiertage)
|
|
let totalMinutes = 0;
|
|
Object.values(dayData).forEach(day => {
|
|
// Krankheitsstunden (haben Vorrang)
|
|
if (day.sick) {
|
|
totalMinutes += day.sick.hours * 60;
|
|
} else {
|
|
// Nettoarbeitszeit
|
|
if (day.netWorkTime) {
|
|
const [hours, minutes] = day.netWorkTime.split(':').map(Number);
|
|
totalMinutes += hours * 60 + minutes;
|
|
}
|
|
|
|
// Feiertagsstunden
|
|
if (day.holiday) {
|
|
totalMinutes += day.holiday.hours * 60;
|
|
}
|
|
|
|
// Urlaubsstunden
|
|
if (day.vacation) {
|
|
totalMinutes += day.vacation.hours * 60;
|
|
}
|
|
}
|
|
});
|
|
|
|
const weekTotalHours = Math.floor(totalMinutes / 60);
|
|
const weekTotalMinutes = totalMinutes % 60;
|
|
const weekTotal = `${weekTotalHours}:${weekTotalMinutes.toString().padStart(2, '0')}`;
|
|
|
|
// Berechne nur die Arbeitszeit (ohne arbeitsfreie Tage)
|
|
let workMinutes = 0;
|
|
Object.values(dayData).forEach(day => {
|
|
if (day.netWorkTime && !day.vacation && !day.sick && !day.holiday) {
|
|
const [hours, minutes] = day.netWorkTime.split(':').map(Number);
|
|
workMinutes += hours * 60 + minutes;
|
|
}
|
|
});
|
|
|
|
const workHours = Math.floor(workMinutes / 60);
|
|
const workMins = workMinutes % 60;
|
|
const workTotal = `${workHours}:${workMins.toString().padStart(2, '0')}`;
|
|
|
|
// Berechne arbeitsfreie Stunden für die Woche (separat für Anzeige)
|
|
let nonWorkingMinutes = 0;
|
|
let nonWorkingDays = 0;
|
|
const nonWorkingDetails = [];
|
|
|
|
Object.values(dayData).forEach(day => {
|
|
if (day.vacation && day.vacation.hours > 0) {
|
|
nonWorkingMinutes += day.vacation.hours * 60;
|
|
nonWorkingDays++;
|
|
nonWorkingDetails.push({
|
|
date: day.date,
|
|
type: 'Urlaub',
|
|
hours: day.vacation.hours
|
|
});
|
|
}
|
|
if (day.sick && day.sick.hours > 0) {
|
|
nonWorkingMinutes += day.sick.hours * 60;
|
|
nonWorkingDays++;
|
|
nonWorkingDetails.push({
|
|
date: day.date,
|
|
type: 'Krankheit',
|
|
hours: day.sick.hours
|
|
});
|
|
}
|
|
if (day.holiday && day.holiday.hours > 0) {
|
|
nonWorkingMinutes += day.holiday.hours * 60;
|
|
nonWorkingDays++;
|
|
nonWorkingDetails.push({
|
|
date: day.date,
|
|
type: 'Feiertag',
|
|
hours: day.holiday.hours
|
|
});
|
|
}
|
|
});
|
|
|
|
const nonWorkingHours = Math.floor(nonWorkingMinutes / 60);
|
|
const nonWorkingMins = nonWorkingMinutes % 60;
|
|
const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`;
|
|
|
|
// Gesamtsumme = Arbeitszeit + arbeitsfreie Zeit
|
|
const totalAllMinutes = workMinutes + nonWorkingMinutes;
|
|
const totalAllHours = Math.floor(totalAllMinutes / 60);
|
|
const totalAllMins = totalAllMinutes % 60;
|
|
const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`;
|
|
|
|
return {
|
|
weekStart: weekStart.toISOString().split('T')[0],
|
|
weekEnd: weekEnd.toISOString().split('T')[0],
|
|
weekOffset,
|
|
days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)),
|
|
weekTotal: workTotal, // Nur Arbeitszeit
|
|
nonWorkingTotal,
|
|
nonWorkingDays,
|
|
nonWorkingDetails,
|
|
totalAll // Arbeitszeit + arbeitsfreie Zeit
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Aktuellen Status/Zustand des Benutzers abrufen
|
|
* @param {number} userId - Benutzer-ID
|
|
* @returns {Promise<string|null>} Letzter Action-Status oder null
|
|
*/
|
|
async getCurrentState(userId) {
|
|
const uid = userId || this.defaultUserId;
|
|
|
|
// Hole letzten Worklog-Eintrag (inkl. Timefix-Korrekturen)
|
|
const lastEntry = await worklogRepository.findLatestByUser(uid);
|
|
|
|
if (!lastEntry) {
|
|
return null;
|
|
}
|
|
|
|
// Parse state
|
|
let state = lastEntry.state;
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (e) {
|
|
// state bleibt als String
|
|
}
|
|
}
|
|
|
|
const action = state?.action || state;
|
|
|
|
// Prüfe auf Timefix-Korrektur für diesen Eintrag
|
|
const timefixMap = await worklogRepository.getTimefixesByWorklogIds([lastEntry.id]);
|
|
const timefix = timefixMap.get(lastEntry.id)?.[0];
|
|
|
|
if (timefix) {
|
|
// Verwende korrigierten Action-Typ
|
|
return timefix.fix_type;
|
|
}
|
|
|
|
return action;
|
|
}
|
|
|
|
/**
|
|
* Stempeln (Clock-Aktion durchführen)
|
|
* @param {number} userId - Benutzer-ID
|
|
* @param {string} action - Aktion: 'start work', 'stop work', 'start pause', 'stop pause'
|
|
* @returns {Promise<Object>} Erstellter Worklog-Eintrag
|
|
*/
|
|
async clock(userId, action) {
|
|
const uid = userId || this.defaultUserId;
|
|
|
|
// Validiere Action
|
|
const validActions = ['start work', 'stop work', 'start pause', 'stop pause'];
|
|
if (!validActions.includes(action)) {
|
|
throw new Error('Ungültige Aktion');
|
|
}
|
|
|
|
// Hole aktuellen Status
|
|
const currentState = await this.getCurrentState(uid);
|
|
|
|
// Validiere State-Transition
|
|
const isValidTransition = this._validateTransition(currentState, action);
|
|
if (!isValidTransition) {
|
|
throw new Error(`Ungültiger Übergang: ${currentState} -> ${action}`);
|
|
}
|
|
|
|
// Erstelle Worklog-Eintrag
|
|
const relatedToId = await this._getRelatedToId(currentState, action, uid);
|
|
|
|
// Die DB speichert UTC-Zeit (ohne TZ-Info im String)
|
|
// Format: YYYY-MM-DD HH:MM:SS (immer UTC)
|
|
// Das Frontend konvertiert dann zu lokaler Zeit für die Anzeige
|
|
const now = new Date();
|
|
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
const hours = String(now.getUTCHours()).padStart(2, '0');
|
|
const minutes = String(now.getUTCMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getUTCSeconds()).padStart(2, '0');
|
|
const utcTimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
|
|
const entry = await worklogRepository.create({
|
|
user_id: uid,
|
|
state: action, // Speichere nur den Action-String, nicht als JSON
|
|
tstamp: utcTimeString, // UTC-Zeit als String
|
|
relatedTo_id: relatedToId
|
|
});
|
|
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Validiert ob eine State-Transition erlaubt ist
|
|
* @private
|
|
*/
|
|
_validateTransition(currentState, newAction) {
|
|
const transitions = {
|
|
'null': ['start work'],
|
|
'stop work': ['start work'],
|
|
'start work': ['stop work', 'start pause'],
|
|
'stop pause': ['stop work', 'start pause'],
|
|
'start pause': ['stop pause']
|
|
};
|
|
|
|
const allowedActions = transitions[currentState || 'null'] || [];
|
|
return allowedActions.includes(newAction);
|
|
}
|
|
|
|
/**
|
|
* Ermittelt die relatedTo_id für einen neuen Eintrag
|
|
* @private
|
|
*/
|
|
async _getRelatedToId(currentState, newAction, userId) {
|
|
if (newAction === 'start work') {
|
|
return null; // Start work hat keine Referenz
|
|
}
|
|
|
|
// stop work und stop pause referenzieren den entsprechenden Start
|
|
if (newAction === 'stop work') {
|
|
// Finde letzten 'start work' ohne 'stop work'
|
|
return await this._findLastUnpairedStart(userId, 'start work');
|
|
}
|
|
|
|
if (newAction === 'stop pause') {
|
|
// Finde letzten 'start pause' ohne 'stop pause'
|
|
return await this._findLastUnpairedStart(userId, 'start pause');
|
|
}
|
|
|
|
if (newAction === 'start pause') {
|
|
// start pause referenziert den laufenden 'start work'
|
|
return await this._findLastUnpairedStart(userId, 'start work');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Findet den letzten Start-Eintrag ohne passendes End
|
|
* @private
|
|
*/
|
|
async _findLastUnpairedStart(userId, startAction) {
|
|
const allEntries = await worklogRepository.findByUser(userId);
|
|
|
|
// Finde den letzten Start ohne Stop
|
|
for (let i = allEntries.length - 1; i >= 0; i--) {
|
|
const entry = allEntries[i];
|
|
let state = entry.state;
|
|
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (e) {
|
|
// ignore - state bleibt als String
|
|
}
|
|
}
|
|
|
|
const action = state?.action || state;
|
|
|
|
if (action === startAction) {
|
|
// Prüfe ob es einen passenden Stop gibt
|
|
// WICHTIG: Nur nach dem entsprechenden STOP-Action suchen, nicht nach Pausen!
|
|
const stopAction = startAction === 'start work' ? 'stop work' : 'stop pause';
|
|
|
|
const hasStop = allEntries.some(e => {
|
|
// Parse state für diesen Eintrag
|
|
let eState = e.state;
|
|
if (typeof eState === 'string') {
|
|
try {
|
|
eState = JSON.parse(eState);
|
|
} catch (err) {
|
|
// ignore
|
|
}
|
|
}
|
|
const eAction = eState?.action || eState;
|
|
|
|
// Prüfe ob dieser Eintrag ein STOP ist, der auf unseren START zeigt
|
|
return eAction === stopAction && e.relatedTo_id === entry.id;
|
|
});
|
|
|
|
if (!hasStop) {
|
|
return entry.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Hilfsmethode: Wochentag-Namen
|
|
* @private
|
|
* @param {number} dayOfWeek - 0 = Sonntag, 1 = Montag, ..., 6 = Samstag
|
|
*/
|
|
_getDayName(dayOfWeek) {
|
|
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
|
return days[dayOfWeek];
|
|
}
|
|
|
|
/**
|
|
* Aktuell laufenden Timer abrufen
|
|
* @param {number} userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Object|null>} Der laufende Zeiteintrag oder null
|
|
*/
|
|
async getRunningEntry(userId = null) {
|
|
const uid = userId || this.defaultUserId;
|
|
|
|
// Hole letzten Eintrag
|
|
const lastEntry = await worklogRepository.findLatestByUser(uid);
|
|
|
|
if (!lastEntry) {
|
|
return null;
|
|
}
|
|
|
|
// Parse state
|
|
let state = lastEntry.state;
|
|
if (typeof state === 'string') {
|
|
try {
|
|
state = JSON.parse(state);
|
|
} catch (e) {
|
|
// state bleibt als String
|
|
}
|
|
}
|
|
|
|
const action = state?.action || state;
|
|
|
|
// Wenn letzter Eintrag "stop work" oder null ist, läuft nichts
|
|
if (!action || action === 'stop work') {
|
|
return null;
|
|
}
|
|
|
|
// Finde den zugehörigen "start work"
|
|
const startWorkId = await this._findLastUnpairedStart(uid, 'start work');
|
|
|
|
if (!startWorkId) {
|
|
return null;
|
|
}
|
|
|
|
// Hole alle Einträge seit dem Start
|
|
const allEntries = await worklogRepository.findByUser(uid);
|
|
const startWorkEntry = allEntries.find(e => e.id === startWorkId);
|
|
|
|
if (!startWorkEntry) {
|
|
return null;
|
|
}
|
|
|
|
// Sammle alle abgeschlossenen Pausen
|
|
const pauseDurations = [];
|
|
let currentPauseStart = null;
|
|
|
|
// Finde alle Pausen-Paare
|
|
const relevantEntries = allEntries.filter(e => {
|
|
const eDate = new Date(e.tstamp);
|
|
const startDate = new Date(startWorkEntry.tstamp);
|
|
return eDate >= startDate;
|
|
});
|
|
|
|
|
|
const pauseStarts = {};
|
|
|
|
// Hole Timefixes für alle relevanten Einträge (inklusive Pausen)
|
|
const allEntryIds = relevantEntries.map(e => e.id);
|
|
const pauseTimefixMap = await worklogRepository.getTimefixesByWorklogIds(allEntryIds);
|
|
|
|
relevantEntries.forEach(entry => {
|
|
let eState = entry.state;
|
|
if (typeof eState === 'string') {
|
|
try {
|
|
eState = JSON.parse(eState);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const eAction = eState?.action || eState;
|
|
|
|
if (eAction === 'start pause') {
|
|
pauseStarts[entry.id] = entry;
|
|
} else if (eAction === 'stop pause' && entry.relatedTo_id) {
|
|
const startPause = pauseStarts[entry.relatedTo_id];
|
|
if (startPause) {
|
|
// Prüfe auf Timefix-Korrekturen für Pausen
|
|
const pauseStartFix = pauseTimefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause');
|
|
const pauseEndFix = pauseTimefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause');
|
|
|
|
// Verwende korrigierte Zeiten falls vorhanden
|
|
const pStartTime = pauseStartFix?.fix_date_time || startPause.tstamp;
|
|
const pEndTime = pauseEndFix?.fix_date_time || entry.tstamp;
|
|
|
|
const duration = new Date(pEndTime).getTime() - new Date(pStartTime).getTime();
|
|
pauseDurations.push(duration);
|
|
delete pauseStarts[entry.relatedTo_id];
|
|
}
|
|
}
|
|
});
|
|
|
|
// Wenn noch ein pauseStart übrig ist, ist das die laufende Pause
|
|
const runningPauseIds = Object.keys(pauseStarts);
|
|
if (runningPauseIds.length > 0) {
|
|
const pauseId = parseInt(runningPauseIds[0]);
|
|
const pauseEntry = pauseStarts[pauseId];
|
|
|
|
// Prüfe auf Timefix-Korrektur für laufende Pause
|
|
const currentPauseTimefix = pauseTimefixMap.get(pauseId)?.find(f => f.fix_type === 'start pause');
|
|
currentPauseStart = currentPauseTimefix?.fix_date_time || pauseEntry.tstamp;
|
|
}
|
|
|
|
// Prüfe auf Timefix-Korrektur für die Start-Zeit (timefixMap bereits geholt in pauseTimefixMap)
|
|
const startTimefix = pauseTimefixMap.get(startWorkId)?.find(f => f.fix_type === 'start work');
|
|
|
|
let displayStartTime = startWorkEntry.tstamp;
|
|
if (startTimefix && startTimefix.fix_date_time) {
|
|
// Verwende korrigierte Zeit
|
|
displayStartTime = startTimefix.fix_date_time;
|
|
}
|
|
|
|
// Stelle sicher, dass startTime ein String ist (kein Date-Objekt)
|
|
// Konvertiere UTC zu lokaler Zeit für Frontend
|
|
let startTimeStr;
|
|
if (typeof displayStartTime === 'string') {
|
|
// Wenn es ein UTC-String ist, konvertiere zu lokaler Zeit
|
|
const utcDate = new Date(displayStartTime);
|
|
const year = utcDate.getFullYear();
|
|
const month = String(utcDate.getMonth() + 1).padStart(2, '0');
|
|
const day = String(utcDate.getDate()).padStart(2, '0');
|
|
const hours = String(utcDate.getHours()).padStart(2, '0');
|
|
const minutes = String(utcDate.getMinutes()).padStart(2, '0');
|
|
const seconds = String(utcDate.getSeconds()).padStart(2, '0');
|
|
startTimeStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
} else {
|
|
startTimeStr = displayStartTime.toISOString();
|
|
}
|
|
|
|
// Stelle sicher, dass currentPauseStart auch ein String ist
|
|
const currentPauseStartStr = currentPauseStart
|
|
? (typeof currentPauseStart === 'string' ? currentPauseStart : currentPauseStart.toISOString())
|
|
: null;
|
|
|
|
const result = {
|
|
id: startWorkId,
|
|
startTime: startTimeStr, // Explizit als String
|
|
endTime: null,
|
|
description: (state?.description || ''),
|
|
project: (state?.project || 'Allgemein'),
|
|
duration: null,
|
|
isRunning: true,
|
|
userId: uid,
|
|
pauses: pauseDurations,
|
|
currentPauseStart: currentPauseStartStr
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Alle Einträge nach Projekt filtern
|
|
* @param {string} projectName - Name des Projekts
|
|
* @param {number} userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Array>} Gefilterte Liste der Zeiteinträge
|
|
*/
|
|
async getEntriesByProject(projectName, userId = null) {
|
|
const uid = userId || this.defaultUserId;
|
|
const pairs = await worklogRepository.findPairsByUser(uid);
|
|
const safePairs = Array.isArray(pairs) ? pairs : [];
|
|
|
|
return safePairs
|
|
.map(pair => this._mapWorklogPairToTimeEntry(pair))
|
|
.filter(entry =>
|
|
entry.project.toLowerCase() === projectName.toLowerCase()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Alle Einträge in einem Datumsbereich abrufen
|
|
* @param {Date} startDate - Startdatum
|
|
* @param {Date} endDate - Enddatum
|
|
* @param {number} userId - Benutzer-ID (optional)
|
|
* @returns {Promise<Array>} Gefilterte Liste der Zeiteinträge
|
|
*/
|
|
async getEntriesByDateRange(startDate, endDate, userId = null) {
|
|
const uid = userId || this.defaultUserId;
|
|
const worklogs = await worklogRepository.findByDateRange(uid, startDate, endDate);
|
|
|
|
// Zu Paaren gruppieren
|
|
const pairs = [];
|
|
const startEntries = worklogs.filter(w => w.relatedTo_id === null);
|
|
|
|
for (const start of startEntries) {
|
|
const end = worklogs.find(w => w.relatedTo_id === start.id);
|
|
|
|
let duration = null;
|
|
if (end) {
|
|
const startTime = new Date(start.tstamp);
|
|
const endTime = new Date(end.tstamp);
|
|
duration = Math.floor((endTime - startTime) / 1000);
|
|
}
|
|
|
|
pairs.push({
|
|
start_id: start.id,
|
|
start_time: start.tstamp,
|
|
start_state: start.state,
|
|
end_id: end ? end.id : null,
|
|
end_time: end ? end.tstamp : null,
|
|
end_state: end ? end.state : null,
|
|
duration
|
|
});
|
|
}
|
|
|
|
return pairs.map(pair => this._mapWorklogPairToTimeEntry(pair));
|
|
}
|
|
|
|
/**
|
|
* Worklog-Paar zu TimeEntry-Format konvertieren (privat)
|
|
* @private
|
|
*/
|
|
_mapWorklogPairToTimeEntry(pair) {
|
|
let startStateData, endStateData;
|
|
|
|
try {
|
|
startStateData = JSON.parse(pair.start_state);
|
|
} catch {
|
|
startStateData = { project: 'Allgemein', description: '' };
|
|
}
|
|
|
|
try {
|
|
endStateData = pair.end_state ? JSON.parse(pair.end_state) : null;
|
|
} catch {
|
|
endStateData = null;
|
|
}
|
|
|
|
return {
|
|
id: pair.start_id,
|
|
startTime: pair.start_time,
|
|
endTime: pair.end_time || null,
|
|
description: (endStateData && endStateData.description) || startStateData.description || '',
|
|
project: (endStateData && endStateData.project) || startStateData.project || 'Allgemein',
|
|
duration: pair.duration || null,
|
|
isRunning: !pair.end_time,
|
|
userId: null // Wird vom Repository nicht zurückgegeben im Pair
|
|
};
|
|
}
|
|
}
|
|
|
|
// Singleton-Instanz exportieren
|
|
module.exports = new TimeEntryService();
|