Files
timetracker/public/js/main.js

3684 lines
120 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================
// STATE & VARIABLES (additional to state.js)
// ============================================
let manualStartTimePicker = null;
// Additional timer state (beyond state.js)
let pauseStartElapsed = 0; // Elapsed time when pause started (to freeze display)
let pauseEndTime = 0; // Timestamp when pause will end (for countdown)
let timerStartTimeString = ''; // Start time as string (HH:MM) for display
// Current view state
let currentView = 'monthly'; // 'monthly' or 'filter'
let currentFilterFrom = null;
let currentFilterTo = null;
// Settings state
let currentBundesland = 'BW'; // Default: Baden-Württemberg
let totalVacationDays = 30; // Default vacation days per year
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Format date from YYYY-MM-DD to DD.MM.YYYY
*/
function formatDateDisplay(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}.${month}.${year}`;
}
/**
* Format date from DD.MM.YYYY to YYYY-MM-DD
*/
function formatDateISO(dateStr) {
const [day, month, year] = dateStr.split('.');
return `${year}-${month}-${day}`;
}
/**
* Get today's date in YYYY-MM-DD format
*/
function getTodayISO() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Round time down to nearest 15 minutes
*/
function roundDownTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Round time up to nearest 15 minutes
*/
function roundUpTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.ceil(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Format time as HH:MM
*/
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Format seconds to HH:MM:SS
*/
function formatDuration(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/**
* Show toast notification
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer');
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Icon based on type
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span>${message}</span>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, 3000);
}
/**
* Get day of week abbreviation in German
*/
function getDayOfWeek(date) {
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days[date.getDay()];
}
/**
* Check if date is weekend
*/
function isWeekend(date) {
const day = date.getDay();
return day === 0 || day === 6; // Sunday or Saturday
}
/**
* Calculate Easter Sunday for a given year (Gauss algorithm)
*/
function getEasterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
/**
* Get all public holidays for a given year and Bundesland
*/
function getPublicHolidays(year, bundesland = currentBundesland) {
const holidays = [];
// Fixed holidays (all states)
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' });
holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' });
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
// Heilige Drei Könige (BW, BY, ST)
if (['BW', 'BY', 'ST'].includes(bundesland)) {
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
}
// Internationaler Frauentag (BE, MV since 2023)
if (['BE'].includes(bundesland) || (bundesland === 'MV' && year >= 2023)) {
holidays.push({ date: new Date(year, 2, 8), name: 'Internationaler Frauentag' });
}
// Weltkindertag (TH since 2019)
if (bundesland === 'TH' && year >= 2019) {
holidays.push({ date: new Date(year, 8, 20), name: 'Weltkindertag' });
}
// Reformationstag (BB, MV, SN, ST, TH, + HB, HH, NI, SH since 2018)
const reformationstagStates = ['BB', 'MV', 'SN', 'ST', 'TH'];
if (year >= 2018) {
reformationstagStates.push('HB', 'HH', 'NI', 'SH');
}
if (reformationstagStates.includes(bundesland)) {
holidays.push({ date: new Date(year, 9, 31), name: 'Reformationstag' });
}
// Allerheiligen (BW, BY, NW, RP, SL)
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' });
}
// Buß- und Bettag (only SN)
if (bundesland === 'SN') {
// Buß- und Bettag is the Wednesday before November 23
let bussbettag = new Date(year, 10, 23);
while (bussbettag.getDay() !== 3) { // 3 = Wednesday
bussbettag.setDate(bussbettag.getDate() - 1);
}
bussbettag.setDate(bussbettag.getDate() - 7); // One week before
holidays.push({ date: bussbettag, name: 'Buß- und Bettag' });
}
// Easter-dependent holidays
const easter = getEasterSunday(year);
// Karfreitag (Good Friday) - 2 days before Easter (all states)
const goodFriday = new Date(easter);
goodFriday.setDate(easter.getDate() - 2);
holidays.push({ date: goodFriday, name: 'Karfreitag' });
// Ostermontag (Easter Monday) - 1 day after Easter (all states)
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
holidays.push({ date: easterMonday, name: 'Ostermontag' });
// Christi Himmelfahrt (Ascension Day) - 39 days after Easter (all states)
const ascension = new Date(easter);
ascension.setDate(easter.getDate() + 39);
holidays.push({ date: ascension, name: 'Christi Himmelfahrt' });
// Pfingstmontag (Whit Monday) - 50 days after Easter (all states)
const whitMonday = new Date(easter);
whitMonday.setDate(easter.getDate() + 50);
holidays.push({ date: whitMonday, name: 'Pfingstmontag' });
// Fronleichnam (Corpus Christi) - 60 days after Easter (BW, BY, HE, NW, RP, SL, + some communities in SN, TH)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bundesland)) {
const corpusChristi = new Date(easter);
corpusChristi.setDate(easter.getDate() + 60);
holidays.push({ date: corpusChristi, name: 'Fronleichnam' });
}
// Mariä Himmelfahrt (Assumption of Mary) - August 15 (BY in some communities, SL)
if (['SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 7, 15), name: 'Mariä Himmelfahrt' });
}
return holidays;
}
/**
* Check if date is a public holiday
* Returns the holiday name or null
*/
function getHolidayName(date) {
const year = date.getFullYear();
const holidays = getPublicHolidays(year, currentBundesland);
const dateStr = date.toISOString().split('T')[0];
const holiday = holidays.find(h => {
return h.date.toISOString().split('T')[0] === dateStr;
});
return holiday ? holiday.name : null;
}
/**
* Check if date is a public holiday in Baden-Württemberg
*/
function isPublicHoliday(date) {
return getHolidayName(date) !== null;
}
/**
* Check if date is weekend or public holiday
*/
function isWeekendOrHoliday(date) {
return isWeekend(date) || isPublicHoliday(date);
}
/**
* Get month name in German
*/
function getMonthName(month) {
const months = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return months[month];
}
/**
* Update month display
*/
function updateMonthDisplay() {
const monthName = getMonthName(displayMonth);
const displayText = `${monthName} ${displayYear}`;
document.getElementById('currentMonthDisplay').textContent = displayText;
// Always show next month button (allow navigation to future)
const nextBtn = document.getElementById('btnNextMonth');
nextBtn.style.visibility = 'visible';
}
/**
* Navigate to previous month
*/
function handlePrevMonth() {
if (displayMonth === 0) {
displayMonth = 11;
displayYear--;
} else {
displayMonth--;
}
loadMonthlyView();
}
/**
* Navigate to next month
*/
function handleNextMonth() {
// Allow navigation to future months
if (displayMonth === 11) {
displayMonth = 0;
displayYear++;
} else {
displayMonth++;
}
loadMonthlyView();
}
// ============================================
// TIMER FUNCTIONS
// ============================================
/**
* Check if timer is running (on page load)
*/
async function checkRunningTimer() {
const today = getTodayISO();
const entries = await fetchEntries(today, today);
if (entries.length > 0) {
const todayEntry = entries[0];
// Check if entry has start but same start and end (running timer)
if (todayEntry.startTime === todayEntry.endTime) {
// Timer is running
currentEntryId = todayEntry.id;
// Calculate start time from DB
const [hours, minutes] = todayEntry.startTime.split(':').map(Number);
const startDate = new Date();
startDate.setHours(hours, minutes, 0, 0);
timerStartTime = startDate.getTime();
timerStartTimeString = todayEntry.startTime;
// Update UI
const startBtn = document.getElementById('btnStartWork');
const stopBtn = document.getElementById('btnStopWork');
startBtn.disabled = true;
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
startBtn.classList.remove('hover:bg-green-700');
stopBtn.disabled = false;
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
stopBtn.classList.add('hover:bg-red-700');
document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime;
document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300');
document.getElementById('timerStatus').classList.add('cursor-default');
// Calculate elapsed time and check for active pauses
const elapsed = Date.now() - timerStartTime;
const elapsedSeconds = Math.floor(elapsed / 1000);
// Check if we're in a pause
const sixHoursSeconds = 6 * 60 * 60;
const nineHoursSeconds = 9 * 60 * 60;
const thirtyMinutes = 30 * 60;
const fifteenMinutes = 15 * 60;
// Check if in 6-hour pause (6h to 6h30m real time)
if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) {
isPaused = true;
pauseStartElapsed = sixHoursSeconds;
timerPausedDuration = 0;
const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds;
pauseEndTime = Date.now() + (remainingPause * 1000);
document.getElementById('timerStatus').textContent = `Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`;
// Schedule end of pause
pauseTimeout = setTimeout(() => {
timerPausedDuration = thirtyMinutes;
isPaused = false;
pauseStartElapsed = 0;
pauseEndTime = 0;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime;
}, remainingPause * 1000);
}
// Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time)
else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
isPaused = true;
pauseStartElapsed = nineHoursSeconds; // Work time when pause starts
timerPausedDuration = thirtyMinutes;
const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds;
pauseEndTime = Date.now() + (remainingPause * 1000);
document.getElementById('timerStatus').textContent = `Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`;
// Schedule end of pause
pauseTimeout = setTimeout(() => {
timerPausedDuration = thirtyMinutes + fifteenMinutes;
isPaused = false;
pauseStartElapsed = 0;
pauseEndTime = 0;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime;
}, remainingPause * 1000);
}
// Not in pause, but may have completed pauses
else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) {
// After first pause, before second pause
timerPausedDuration = thirtyMinutes;
} else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
// After both pauses
timerPausedDuration = thirtyMinutes + fifteenMinutes;
}
// Start timer interval
timerInterval = setInterval(updateTimer, 1000);
updateTimer(); // Immediate update
// Schedule automatic pauses
schedulePausesWithOffset(elapsed);
}
}
}
/**
* Start work timer
*/
async function startWork() {
const now = new Date();
const roundedStart = roundDownTo15Min(new Date(now));
const startTime = formatTime(roundedStart);
const today = getTodayISO();
// Create entry with same start and end time (indicates running)
const entry = await createEntry(formatDateDisplay(today), startTime, startTime, null);
if (!entry) {
return;
}
currentEntryId = entry.id;
timerStartTime = roundedStart.getTime();
timerStartTimeString = startTime;
timerPausedDuration = 0;
isPaused = false;
// Update UI
const startBtn = document.getElementById('btnStartWork');
const stopBtn = document.getElementById('btnStopWork');
startBtn.disabled = true;
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
startBtn.classList.remove('hover:bg-green-700');
stopBtn.disabled = false;
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
stopBtn.classList.add('hover:bg-red-700');
document.getElementById('timerStatus').textContent = 'Läuft seit ' + startTime;
document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300');
document.getElementById('timerStatus').classList.add('cursor-default');
// Start timer interval
timerInterval = setInterval(updateTimer, 1000);
// Schedule automatic pauses
schedulePausesWithOffset(0);
// Reload view to show entry
loadMonthlyView();
}
/**
* Stop work timer
*/
async function stopWork() {
if (!currentEntryId) {
return;
}
clearInterval(timerInterval);
if (pauseTimeout) clearTimeout(pauseTimeout);
const now = new Date();
const roundedEnd = roundUpTo15Min(new Date(now));
const endTime = formatTime(roundedEnd);
const today = getTodayISO();
// Fetch the entry to get start time
const entries = await fetchEntries(today, today);
const entry = entries.find(e => e.id === currentEntryId);
if (entry) {
// Update entry with end time
await updateEntry(currentEntryId, formatDateDisplay(today), entry.startTime, endTime, null);
}
// Reset timer state
currentEntryId = null;
timerStartTime = null;
timerPausedDuration = 0;
isPaused = false;
// Update UI
const startBtn = document.getElementById('btnStartWork');
const stopBtn = document.getElementById('btnStopWork');
startBtn.disabled = false;
startBtn.classList.remove('opacity-50', 'cursor-not-allowed');
startBtn.classList.add('hover:bg-green-700');
stopBtn.disabled = true;
stopBtn.classList.add('opacity-50', 'cursor-not-allowed');
stopBtn.classList.remove('hover:bg-red-700');
document.getElementById('timerDisplay').textContent = '00:00:00';
document.getElementById('timerStatus').textContent = 'Nicht gestartet';
document.getElementById('timerStatus').classList.add('cursor-pointer', 'underline', 'hover:text-blue-300');
document.getElementById('timerStatus').classList.remove('cursor-default');
// Reload monthly view
loadMonthlyView();
}
/**
* Update timer display
*/
function updateTimer() {
if (!timerStartTime) return;
const now = Date.now();
let elapsed;
// If currently paused, show the frozen time (time when pause started)
if (isPaused) {
elapsed = pauseStartElapsed;
// Update pause countdown live
const remainingSeconds = Math.max(0, Math.ceil((pauseEndTime - now) / 1000));
const remainingMinutes = Math.ceil(remainingSeconds / 60);
document.getElementById('timerStatus').textContent = `Läuft seit ${timerStartTimeString} - Pause (${remainingMinutes} Min)`;
} else {
elapsed = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration;
}
document.getElementById('timerDisplay').textContent = formatDuration(elapsed);
// Update live net hours in the table for current day
const netHoursCell = document.getElementById('current-day-net-hours');
if (netHoursCell) {
const netHours = elapsed / 3600; // Convert seconds to hours
netHoursCell.textContent = netHours.toFixed(2);
}
// Update live pause minutes in the table for current day
const pauseCell = document.getElementById('current-day-pause');
if (pauseCell) {
// Calculate total pause time in minutes
let totalPauseMinutes = Math.floor(timerPausedDuration / 60);
// If currently in a pause, add the elapsed pause time
if (isPaused) {
const pauseDuration = Math.floor((now - (pauseEndTime - (isPaused ? (pauseEndTime - now) : 0))) / 1000);
// Determine which pause we're in and add its duration to the display
const sixHoursSeconds = 6 * 60 * 60;
const nineHoursSeconds = 9 * 60 * 60;
const elapsedTotal = Math.floor((now - timerStartTime) / 1000);
if (elapsedTotal >= sixHoursSeconds && elapsedTotal < sixHoursSeconds + 30 * 60) {
// In 6-hour pause (30 minutes)
totalPauseMinutes = 30;
} else if (elapsedTotal >= nineHoursSeconds + 30 * 60 && elapsedTotal < nineHoursSeconds + 30 * 60 + 15 * 60) {
// In 9-hour pause (30 + 15 minutes)
totalPauseMinutes = 30 + 15;
}
}
pauseCell.textContent = totalPauseMinutes;
}
}
/**
* Schedule automatic pauses at 6h and 9h with offset for existing elapsed time
*/
function schedulePausesWithOffset(elapsedMs) {
const sixHoursMs = 6 * 60 * 60 * 1000;
const nineHoursMs = 9 * 60 * 60 * 1000;
// Pause at 6 hours for 30 minutes
if (elapsedMs < sixHoursMs) {
setTimeout(() => {
if (timerStartTime && !isPaused) {
pauseTimer(30 * 60); // 30 minutes
showNotification('⏸️ Automatische Pause: 30 Minuten (nach 6 Stunden)', 'info');
}
}, sixHoursMs - elapsedMs);
}
// Additional pause at 9 hours for 15 minutes
if (elapsedMs < nineHoursMs) {
setTimeout(() => {
if (timerStartTime && !isPaused) {
pauseTimer(15 * 60); // 15 minutes
showNotification('⏸️ Automatische Pause: 15 Minuten (nach 9 Stunden)', 'info');
}
}, nineHoursMs - elapsedMs);
}
}
/**
* Pause timer for specified duration
*/
function pauseTimer(durationSeconds) {
isPaused = true;
pauseEndTime = Date.now() + (durationSeconds * 1000);
// Store the elapsed time when pause starts (this is what we want to freeze at)
pauseStartElapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration;
pauseTimeout = setTimeout(() => {
timerPausedDuration += durationSeconds;
isPaused = false;
pauseEndTime = 0;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + timerStartTimeString;
}, durationSeconds * 1000);
}
/**
* Fetch entries from the backend
*/
async function fetchEntries(fromDate = null, toDate = null) {
try {
let url = '/api/entries';
const params = new URLSearchParams();
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch entries');
}
const entries = await response.json();
return entries;
} catch (error) {
console.error('Error fetching entries:', error);
showNotification('Fehler beim Laden der Einträge', 'error');
return [];
}
}
/**
* Create a new entry
*/
async function createEntry(date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch('/api/entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error creating entry:', error);
showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error');
return null;
}
}
/**
* Update an existing entry
*/
async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch(`/api/entries/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error updating entry:', error);
showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error');
return null;
}
}
/**
* Delete an entry
*/
async function deleteEntry(id) {
try {
const response = await fetch(`/api/entries/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete entry');
}
return true;
} catch (error) {
console.error('Error deleting entry:', error);
showNotification('Fehler beim Löschen des Eintrags', 'error');
return false;
}
}
/**
* Get a setting by key
*/
async function getSetting(key) {
try {
const response = await fetch(`/api/settings/${key}`);
if (!response.ok) {
if (response.status === 404) {
return null; // Setting doesn't exist yet
}
throw new Error('Failed to get setting');
}
const data = await response.json();
return data.value;
} catch (error) {
console.error('Error getting setting:', error);
return null;
}
}
/**
* Set a setting
*/
async function setSetting(key, value) {
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key, value })
});
if (!response.ok) {
throw new Error('Failed to set setting');
}
return true;
} catch (error) {
console.error('Error setting setting:', error);
showNotification('Fehler beim Speichern der Einstellung', 'error');
return false;
}
}
/**
* Export entries as CSV
*/
async function exportEntries(fromDate = null, toDate = null) {
try {
let url = '/api/export';
const params = new URLSearchParams();
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to export entries');
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'zeiterfassung.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
showNotification('Export erfolgreich', 'success');
} catch (error) {
console.error('Error exporting entries:', error);
showNotification('Fehler beim Exportieren', 'error');
}
}
// ============================================
// UI FUNCTIONS
// ============================================
/**
* Render entries in the table
*/
function renderEntries(entries) {
const tbody = document.getElementById('entriesTableBody');
const emptyState = document.getElementById('emptyState');
tbody.innerHTML = '';
if (entries.length === 0) {
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
entries.forEach(entry => {
const row = document.createElement('tr');
const dateObj = new Date(entry.date + 'T00:00:00');
const dayOfWeek = getDayOfWeek(dateObj);
const weekend = isWeekendOrHoliday(dateObj);
const location = entry.location || 'office';
// Row color based on location
let rowClass = 'hover:bg-gray-700';
if (location === 'home') {
rowClass = 'hover:bg-green-900 bg-green-950';
} else if (weekend) {
rowClass = 'hover:bg-gray-700 bg-gray-700';
}
row.className = rowClass;
row.dataset.id = entry.id;
// Location icon and text
const locationIcon = location === 'home'
? '<i data-lucide="home" class="w-4 h-4 inline"></i>'
: '<i data-lucide="building-2" class="w-4 h-4 inline"></i>';
const locationText = location === 'home' ? 'Home' : 'Büro';
// Checkbox column (always present for consistent layout)
const checkboxCell = bulkEditMode ? `
<td class="px-2 py-4 whitespace-nowrap text-center">
<input type="checkbox" class="entry-checkbox w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
data-id="${entry.id}" ${selectedEntries.has(entry.id) ? 'checked' : ''}>
</td>
` : '<td class="hidden"></td>';
row.innerHTML = checkboxCell + `
<td class="px-2 py-4 whitespace-nowrap text-sm font-medium ${weekend ? 'text-blue-400' : 'text-gray-100'}">${dayOfWeek}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${formatDateDisplay(entry.date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 editable-cell"
data-field="startTime" data-id="${entry.id}" data-value="${entry.startTime}">
${entry.startTime}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 editable-cell"
data-field="endTime" data-id="${entry.id}" data-value="${entry.endTime}">
${entry.endTime}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 editable-cell"
data-field="pauseMinutes" data-id="${entry.id}" data-value="${entry.pauseMinutes}">
${entry.pauseMinutes}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-100">${entry.netHours.toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-100">
<span class="text-lg">${locationIcon}</span> ${locationText}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center">
<div class="flex gap-2 justify-center">
<button class="btn-edit inline-flex items-center justify-center w-8 h-8 text-blue-500 hover:text-blue-600 hover:bg-blue-500/10 rounded transition-all" data-id="${entry.id}" title="Bearbeiten">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
<button class="btn-delete inline-flex items-center justify-center w-8 h-8 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded transition-all" data-id="${entry.id}" title="Löschen">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// Reinitialize Lucide icons
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
// Add event listeners
attachInlineEditListeners();
document.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const entries = await fetchEntries();
const entry = entries.find(e => e.id === id);
if (entry) {
openModal(entry);
}
});
});
document.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id)));
});
// Checkbox event listeners for bulk edit
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', () => {
toggleEntrySelection(parseInt(checkbox.dataset.id));
});
});
}
/**
* Render monthly view with all days
*/
function renderMonthlyView(entries) {
const tbody = document.getElementById('entriesTableBody');
const emptyState = document.getElementById('emptyState');
tbody.innerHTML = '';
emptyState.classList.add('hidden');
const today = new Date();
const todayYear = today.getFullYear();
const todayMonth = today.getMonth();
const todayDay = today.getDate();
// Show all days of the month
const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate();
// Create a map of entries by date
const entriesMap = {};
entries.forEach(entry => {
entriesMap[entry.date] = entry;
});
// Render all days from 1st to lastDay
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(displayYear, displayMonth, day);
const dateISO = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const entry = entriesMap[dateISO];
const dayOfWeek = getDayOfWeek(dateObj);
const weekend = isWeekendOrHoliday(dateObj);
// Check if this is today
const isToday = dateISO === getTodayISO();
const row = document.createElement('tr');
if (entry) {
// Day has entry
const location = entry.location || 'office';
const entryType = entry.entryType || 'work';
// Row color based on entry type and location
let rowClass = 'hover:bg-gray-700';
if (entryType === 'vacation') {
rowClass = 'hover:bg-yellow-900 bg-yellow-950';
} else if (entryType === 'flextime') {
rowClass = 'hover:bg-cyan-900 bg-cyan-950';
} else if (location === 'home') {
rowClass = 'hover:bg-green-900 bg-green-950';
} else if (weekend) {
rowClass = 'hover:bg-gray-700 bg-gray-700';
}
row.className = rowClass;
row.dataset.id = entry.id;
// Add inline border style for today
if (isToday) {
row.style.borderLeft = '4px solid #3b82f6'; // blue-500
row.id = 'current-day-row'; // Add ID for live updates
}
// Icon and text based on entry type
let displayIcon, displayText, displayTimes;
if (entryType === 'vacation') {
displayIcon = '<i data-lucide="plane" class="w-4 h-4 inline"></i>';
displayText = 'Urlaub';
displayTimes = `<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
<span class="italic">Urlaub</span>
</td>`;
} else if (entryType === 'flextime') {
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
displayText = 'Gleitzeit';
displayTimes = `<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
<span class="italic">Gleittag (8h)</span>
</td>`;
} else {
displayIcon = location === 'home'
? '<i data-lucide="home" class="w-4 h-4 inline"></i>'
: '<i data-lucide="building-2" class="w-4 h-4 inline"></i>';
displayText = location === 'home' ? 'Home' : 'Büro';
// Check if timer is running (start == end time)
const isTimerRunning = entry.startTime === entry.endTime;
const endTimeDisplay = isTimerRunning
? '<i data-lucide="clock" class="w-4 h-4 inline timer-running-icon"></i>'
: entry.endTime;
displayTimes = `
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 editable-cell"
data-field="startTime" data-id="${entry.id}" data-value="${entry.startTime}">
${entry.startTime}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 ${isTimerRunning ? '' : 'editable-cell'}"
${isTimerRunning ? '' : `data-field="endTime" data-id="${entry.id}" data-value="${entry.endTime}"`}>
${endTimeDisplay}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100 ${isTimerRunning ? '' : 'editable-cell'}"
${isTimerRunning ? `id="current-day-pause"` : `data-field="pauseMinutes" data-id="${entry.id}" data-value="${entry.pauseMinutes}"`}>
${entry.pauseMinutes}
</td>`;
}
// Checkbox column (always present for consistent layout)
const checkboxCell = bulkEditMode ? `
<td class="px-2 py-4 whitespace-nowrap text-center">
<input type="checkbox" class="entry-checkbox w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
data-id="${entry.id}" ${selectedEntries.has(entry.id) ? 'checked' : ''}>
</td>
` : '<td class="hidden"></td>';
row.innerHTML = checkboxCell + `
<td class="px-2 py-4 whitespace-nowrap text-sm font-medium ${weekend ? 'text-blue-400' : 'text-gray-100'}">${dayOfWeek}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${formatDateDisplay(entry.date)}</td>
${displayTimes}
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-100" id="${isToday ? 'current-day-net-hours' : ''}">${entry.netHours.toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-100">
<span class="text-lg">${displayIcon}</span> ${displayText}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center">
<div class="flex gap-2 justify-center">
${entryType === 'work' ? `
<button class="btn-edit inline-flex items-center justify-center w-8 h-8 text-blue-500 hover:text-blue-600 hover:bg-blue-500/10 rounded transition-all" data-id="${entry.id}" title="Bearbeiten">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
` : ''}
<button class="btn-delete inline-flex items-center justify-center w-8 h-8 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded transition-all" data-id="${entry.id}" title="Löschen">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
`;
} else {
// Day has no entry - show add options
const holidayName = getHolidayName(dateObj);
const displayText = holidayName || 'Kein Eintrag';
// Don't mark future days as red, only past workdays without entries
const isFutureDay = dateObj > today;
let emptyRowClass = weekend ? 'hover:bg-gray-700 bg-gray-700' :
isFutureDay ? 'hover:bg-gray-700' : 'hover:bg-gray-700 bg-red-950/40';
row.className = emptyRowClass;
row.dataset.date = dateISO; // Store date for bulk operations
// Add inline border style for today
if (isToday) {
row.style.borderLeft = '4px solid #3b82f6'; // blue-500
}
// Checkbox cell for empty days (in bulk edit mode)
const checkboxCell = bulkEditMode ? `
<td class="px-2 py-4 whitespace-nowrap text-center">
<input type="checkbox" class="empty-day-checkbox w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
data-date="${dateISO}" ${selectedEntries.has(dateISO) ? 'checked' : ''}>
</td>
` : '<td class="hidden"></td>';
const colspan = bulkEditMode ? '5' : '5';
row.innerHTML = checkboxCell + `
<td class="px-2 py-4 whitespace-nowrap text-sm font-medium ${weekend ? 'text-blue-400' : 'text-gray-100'}">${dayOfWeek}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${formatDateDisplay(dateISO)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm ${holidayName ? 'text-blue-400 font-semibold' : 'text-gray-500'}" colspan="${colspan}">
<span class="italic">${displayText}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-center">
<div class="flex gap-1 justify-center">
<button class="btn-add-work inline-flex items-center justify-center w-7 h-7 text-indigo-500 hover:text-indigo-600 hover:bg-indigo-500/10 rounded transition-all" data-date="${dateISO}" title="Arbeit eintragen">
<i data-lucide="plus" class="w-4 h-4"></i>
</button>
${!weekend ? `
<button class="btn-add-vacation inline-flex items-center justify-center w-7 h-7 text-amber-500 hover:text-amber-600 hover:bg-amber-500/10 rounded transition-all" data-date="${dateISO}" title="Urlaub eintragen">
<i data-lucide="plane" class="w-4 h-4"></i>
</button>
<button class="btn-add-flextime inline-flex items-center justify-center w-7 h-7 text-cyan-500 hover:text-cyan-600 hover:bg-cyan-500/10 rounded transition-all" data-date="${dateISO}" title="Gleitzeit eintragen">
<i data-lucide="clock" class="w-4 h-4"></i>
</button>
` : ''}
</div>
</td>
`;
}
tbody.appendChild(row);
}
// Reinitialize Lucide icons
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
// Add event listeners
attachInlineEditListeners();
document.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const entries = await fetchEntries();
const entry = entries.find(e => e.id === id);
if (entry) {
openModal(entry);
}
});
});
document.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id)));
});
// Add work entry for a specific date
document.querySelectorAll('.btn-add-work').forEach(btn => {
btn.addEventListener('click', () => {
const dateISO = btn.dataset.date;
openModalForDate(dateISO);
});
});
// Add vacation entry for a specific date
document.querySelectorAll('.btn-add-vacation').forEach(btn => {
btn.addEventListener('click', async () => {
const dateISO = btn.dataset.date;
await addSpecialEntry(dateISO, 'vacation');
});
});
// Add flextime entry for a specific date
document.querySelectorAll('.btn-add-flextime').forEach(btn => {
btn.addEventListener('click', async () => {
const dateISO = btn.dataset.date;
await addSpecialEntry(dateISO, 'flextime');
});
});
// Checkbox event listeners for bulk edit (existing entries)
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', () => {
toggleEntrySelection(parseInt(checkbox.dataset.id));
});
});
// Checkbox event listeners for bulk edit (empty days)
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', () => {
toggleEmptyDaySelection(checkbox.dataset.date);
});
});
}
/**
* Add a special entry (vacation or flextime) for a specific date
*/
async function addSpecialEntry(dateISO, entryType) {
const typeName = entryType === 'vacation' ? 'Urlaub' : 'Gleittag';
if (!confirm(`${typeName} für ${formatDateDisplay(dateISO)} eintragen?`)) {
return;
}
try {
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: dateISO,
entryType: entryType
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Erstellen');
}
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' : '✅ Gleittag eingetragen (8h Soll, 0h Ist)';
showNotification(message, 'success');
loadMonthlyView();
} catch (error) {
showNotification(error.message, 'error');
}
}
/**
* Load and display entries
*/
async function loadEntries(fromDate = null, toDate = null) {
const entries = await fetchEntries(fromDate, toDate);
renderEntries(entries);
}
/**
* Load and display monthly view
*/
async function loadMonthlyView() {
currentView = 'monthly';
showMonthNavigation();
// First day of display month
const fromDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-01`;
// Last day of display month
const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate();
const toDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const entries = await fetchEntries(fromDate, toDate);
renderMonthlyView(entries);
updateMonthDisplay();
updateStatistics(entries);
// Show/hide PDF export button based on whether month is complete
const today = new Date();
const isCurrentOrFutureMonth = displayYear > today.getFullYear() ||
(displayYear === today.getFullYear() && displayMonth >= today.getMonth());
const pdfButton = document.getElementById('btnExportPDF');
if (isCurrentOrFutureMonth) {
pdfButton.style.display = 'none';
} else {
pdfButton.style.display = '';
}
}
/**
* Reload the current view (monthly or filter)
*/
async function reloadView() {
if (currentView === 'filter') {
const entries = await fetchEntries(currentFilterFrom, currentFilterTo);
renderEntries(entries);
} else {
await loadMonthlyView();
}
}
/**
* Show month navigation controls
*/
function showMonthNavigation() {
const nav = document.getElementById('monthNavigation');
if (nav) nav.classList.remove('hidden');
}
/**
* Hide month navigation controls
*/
function hideMonthNavigation() {
const nav = document.getElementById('monthNavigation');
if (nav) nav.classList.add('hidden');
}
/**
* Calculate and update statistics for the current month
*/
async function updateStatistics(entries) {
const today = new Date();
const currentYear = displayYear;
const currentMonth = displayMonth;
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
// Count workdays (excluding weekends and holidays, only up to today)
let workdaysCount = 0;
let workdaysPassed = 0;
let totalWorkdaysInMonth = 0; // For statistics display
// Count vacation days to exclude from workdays
const vacationDays = new Set(
entries
.filter(e => e.entryType === 'vacation')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set(
entries
.filter(e => e.entryType === 'flextime')
.map(e => e.date)
);
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${year}-${month}-${dayStr}`;
const isVacation = vacationDays.has(dateISO);
const isFlextime = flextimeDays.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) {
// Normal workday (excluding vacation days)
totalWorkdaysInMonth++;
workdaysCount++;
if (dateObj <= today) {
workdaysPassed++;
}
} else if (isFlextime && isWeekendHoliday) {
// Flextime on weekend/holiday counts as additional workday
totalWorkdaysInMonth++;
if (new Date(dateISO) <= today) {
workdaysPassed++;
}
}
// Vacation days are excluded from all counts
}
// Calculate target hours (8h per workday passed)
const targetHours = workdaysPassed * 8;
// Calculate actual hours worked (only up to today)
let actualHours = entries
.filter(e => new Date(e.date) <= today)
.reduce((sum, entry) => sum + entry.netHours, 0);
// Add currently running timer hours to actual hours (only for current month)
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
if (timerStartTime && isCurrentMonth) {
const now = Date.now();
let elapsedSeconds;
// Use same logic as timer display - respect pauses
if (isPaused) {
elapsedSeconds = pauseStartElapsed;
} else {
elapsedSeconds = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration;
}
const elapsedHours = elapsedSeconds / 3600;
actualHours += elapsedHours;
}
// Calculate balance for current month
const balance = actualHours - targetHours;
// Calculate previous month balance
const previousBalance = await calculatePreviousBalance();
// Total balance = previous balance + current month balance
const totalBalance = previousBalance + balance;
// Count actual work entries (excluding vacation/flextime, only up to today)
const workEntriesCount = entries.filter(e => {
const entryDate = new Date(e.date);
return entryDate <= today && (!e.entryType || e.entryType === 'work');
}).length;
// Update UI
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`;
// Current month balance
const balanceElement = document.getElementById('statBalance');
balanceElement.textContent = (balance >= 0 ? '+' : '') + balance.toFixed(1) + 'h';
if (balance > 0) {
balanceElement.className = 'text-2xl font-bold text-green-400';
} else if (balance < 0) {
balanceElement.className = 'text-2xl font-bold text-red-400';
} else {
balanceElement.className = 'text-2xl font-bold text-gray-100';
}
// Previous month balance
const prevBalanceElement = document.getElementById('statPreviousBalance');
prevBalanceElement.textContent = (previousBalance >= 0 ? '+' : '') + previousBalance.toFixed(1) + 'h';
// Total balance
const totalBalanceElement = document.getElementById('statTotalBalance');
totalBalanceElement.textContent = (totalBalance >= 0 ? '+' : '') + totalBalance.toFixed(1) + 'h';
if (totalBalance > 0) {
totalBalanceElement.className = 'text-3xl font-bold text-green-400';
} else if (totalBalance < 0) {
totalBalanceElement.className = 'text-3xl font-bold text-red-400';
} else {
totalBalanceElement.className = 'text-3xl font-bold text-gray-100';
}
// Update vacation statistics
await updateVacationStatistics();
}
/**
* Update vacation statistics for the year
*/
async function updateVacationStatistics() {
const currentYear = displayYear; // Use displayed year, not current calendar year
const today = new Date();
// Fetch all entries for the displayed year
const fromDate = `${currentYear}-01-01`;
const toDate = `${currentYear}-12-31`;
const allEntries = await fetchEntries(fromDate, toDate);
// Filter vacation entries
const vacationEntries = allEntries.filter(e => e.entryType === 'vacation');
// Calculate taken (past and today) and planned (future) vacation days
const vacationTaken = vacationEntries.filter(e => new Date(e.date) <= today).length;
const vacationPlanned = vacationEntries.filter(e => new Date(e.date) > today).length;
const vacationRemaining = totalVacationDays - vacationTaken - vacationPlanned;
// Update UI with dynamic year
const vacationLabel = document.getElementById('vacationYearLabel');
vacationLabel.innerHTML = `<i data-lucide="plane" class="w-4 h-4"></i> Urlaub ${currentYear}`;
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
document.getElementById('statVacationTaken').textContent = vacationTaken;
document.getElementById('statVacationPlanned').textContent = vacationPlanned;
document.getElementById('statVacationRemaining').textContent = `${vacationRemaining} / ${totalVacationDays}`;
}
/**
* Calculate balance from all previous months (starting from first month with entries)
*/
async function calculatePreviousBalance() {
const currentYear = displayYear;
const currentMonth = displayMonth;
// Find the first month with any entries by checking all entries
const allEntries = await fetchEntries();
if (allEntries.length === 0) {
return 0;
}
// Find earliest date
const earliestDate = allEntries.reduce((earliest, entry) => {
const entryDate = new Date(entry.date);
return !earliest || entryDate < earliest ? entryDate : earliest;
}, null);
if (!earliestDate) {
return 0;
}
const firstYear = earliestDate.getFullYear();
const firstMonth = earliestDate.getMonth();
// Calculate balance from first month to previous month (not including current displayed month)
let totalBalance = 0;
let checkYear = firstYear;
let checkMonth = firstMonth;
const today = new Date();
// Loop through all months from first entry until previous month of displayed month
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
const firstDay = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-01`;
const lastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
const lastDayStr = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const entries = await fetchEntries(firstDay, lastDayStr);
// For past months, use full month. For current month (if displayed), limit to today
const monthEnd = new Date(checkYear, checkMonth + 1, 0);
const limitDate = monthEnd; // Always use full month for previous balance calculation
let workdaysPassed = 0;
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
// Count vacation days to exclude from workdays
const vacationDays = new Set(
entries
.filter(e => e.entryType === 'vacation')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set(
entries
.filter(e => e.entryType === 'flextime')
.map(e => e.date)
);
for (let day = 1; day <= monthLastDay; day++) {
const dateObj = new Date(checkYear, checkMonth, day);
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${year}-${month}-${dayStr}`;
if (!isWeekendOrHoliday(dateObj)) {
// Exclude vacation days from workdays count
if (!vacationDays.has(dateISO)) {
workdaysPassed++;
}
} else if (flextimeDays.has(dateISO)) {
// Flextime on weekend/holiday counts as workday
workdaysPassed++;
}
}
const targetHours = workdaysPassed * 8;
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
const monthBalance = actualHours - targetHours;
totalBalance += monthBalance;
// Move to next month
checkMonth++;
if (checkMonth > 11) {
checkMonth = 0;
checkYear++;
}
}
return totalBalance;
}
/**
* Open modal for adding/editing entry
*/
function openModal(entry = null) {
const modal = document.getElementById('entryModal');
const modalTitle = document.getElementById('modalTitle');
const dateInput = document.getElementById('modalDate');
const startTimeInput = document.getElementById('modalStartTime');
const endTimeInput = document.getElementById('modalEndTime');
const pauseInput = document.getElementById('modalPause');
const locationInput = document.getElementById('modalLocation');
if (entry) {
// Edit mode
modalTitle.textContent = 'Eintrag bearbeiten';
currentEditingId = entry.id;
dateInput.value = formatDateDisplay(entry.date);
startTimeInput.value = entry.startTime;
endTimeInput.value = entry.endTime;
pauseInput.value = entry.pauseMinutes;
locationInput.value = entry.location || 'office';
updateLocationButtons(entry.location || 'office');
} else {
// Add mode
modalTitle.textContent = 'Neuer Eintrag';
currentEditingId = null;
dateInput.value = '';
startTimeInput.value = '';
endTimeInput.value = '';
pauseInput.value = '';
locationInput.value = 'office';
updateLocationButtons('office');
}
modal.classList.remove('hidden');
}
/**
* Open modal with pre-filled date
*/
function openModalForDate(dateISO) {
const modal = document.getElementById('entryModal');
const modalTitle = document.getElementById('modalTitle');
const dateInput = document.getElementById('modalDate');
const startTimeInput = document.getElementById('modalStartTime');
const endTimeInput = document.getElementById('modalEndTime');
const pauseInput = document.getElementById('modalPause');
const locationInput = document.getElementById('modalLocation');
modalTitle.textContent = 'Neuer Eintrag';
currentEditingId = null;
dateInput.value = formatDateDisplay(dateISO);
startTimeInput.value = '09:00';
endTimeInput.value = '17:30';
pauseInput.value = '';
locationInput.value = 'office';
updateLocationButtons('office');
modal.classList.remove('hidden');
}
/**
* Update location button states
*/
function updateLocationButtons(location) {
const officeBtn = document.getElementById('btnLocationOffice');
const homeBtn = document.getElementById('btnLocationHome');
if (location === 'home') {
officeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2';
homeBtn.className = 'flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm';
} else {
officeBtn.className = 'flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm';
homeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2';
}
// Reinitialize Lucide icons after DOM update
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
}
/**
* Close modal
*/
function closeModal() {
const modal = document.getElementById('entryModal');
modal.classList.add('hidden');
currentEditingId = null;
}
/**
* Handle form submission
*/
async function handleFormSubmit(e) {
e.preventDefault();
const date = document.getElementById('modalDate').value;
const startTime = document.getElementById('modalStartTime').value;
const endTime = document.getElementById('modalEndTime').value;
const pauseInput = document.getElementById('modalPause').value;
const location = document.getElementById('modalLocation').value;
// Convert empty string or "0" to null for auto-calculation
const pauseMinutes = (pauseInput === '' || pauseInput === '0' || parseInt(pauseInput) === 0) ? null : parseInt(pauseInput);
if (!date || !startTime || !endTime) {
showNotification('Bitte alle Felder ausfüllen', 'error');
return;
}
if (currentEditingId) {
// Update existing entry
const success = await updateEntry(currentEditingId, date, startTime, endTime, pauseMinutes, location);
if (success) {
showNotification('Eintrag aktualisiert', 'success');
closeModal();
loadMonthlyView();
}
} else {
// Create new entry
const success = await createEntry(date, startTime, endTime, pauseMinutes, location);
if (success) {
showNotification('Eintrag erstellt', 'success');
closeModal();
loadMonthlyView();
}
}
}
/**
* Handle delete button click
*/
async function handleDelete(id) {
if (confirm('Möchten Sie diesen Eintrag wirklich löschen?')) {
const success = await deleteEntry(id);
if (success) {
showNotification('Eintrag gelöscht', 'success');
loadMonthlyView();
}
}
}
/**
* Handle quick time button click
* Calculates end time based on start time and target net hours
*/
function handleQuickTimeButton(netHours) {
const startTimeInput = document.getElementById('modalStartTime');
const endTimeInput = document.getElementById('modalEndTime');
// Get start time (default to 8:30 if empty)
let startTime = startTimeInput.value || '08:30';
startTimeInput.value = startTime;
// Parse start time
const [startHour, startMin] = startTime.split(':').map(Number);
const startMinutes = startHour * 60 + startMin;
// Calculate required gross hours including pause
let pauseMinutes = 0;
let grossHours = netHours;
if (netHours >= 9) {
pauseMinutes = 45;
grossHours = netHours + (pauseMinutes / 60);
} else if (netHours >= 6) {
pauseMinutes = 30;
grossHours = netHours + (pauseMinutes / 60);
}
// Calculate end time
const endMinutes = startMinutes + (grossHours * 60);
const endHour = Math.floor(endMinutes / 60);
const endMin = Math.round(endMinutes % 60);
const endTime = `${String(endHour).padStart(2, '0')}:${String(endMin).padStart(2, '0')}`;
endTimeInput.value = endTime;
}
/**
* Auto-fill missing entries for current month with 8h net (9:00-17:30)
*/
async function handleAutoFillMonth() {
if (!confirm('Möchten Sie alle fehlenden Arbeitstage im aktuellen Monat mit 8h (9:00-17:30) ausfüllen?\n\nWochenenden und Feiertage werden übersprungen.')) {
return;
}
const today = new Date();
const currentYear = displayYear;
const currentMonth = displayMonth;
// Get last day of current month
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
// Get existing entries for the month
const fromDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-01`;
const toDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const existingEntries = await fetchEntries(fromDate, toDate);
// Create a set of dates that already have entries
const existingDates = new Set(existingEntries.map(e => e.date));
let created = 0;
let skipped = 0;
// Iterate through all days in the month
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
const dateISO = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
// Skip if entry already exists
if (existingDates.has(dateISO)) {
continue;
}
// Skip weekends and holidays
if (isWeekendOrHoliday(dateObj)) {
skipped++;
continue;
}
// Skip future dates
if (dateObj > today) {
skipped++;
continue;
}
// Create entry: 9:00-17:30 (8h net with 30min pause)
const success = await createEntry(formatDateDisplay(dateISO), '09:00', '17:30', null);
if (success) {
created++;
}
}
// Reload view
await reloadView();
showNotification(`${created} Einträge erstellt, ${skipped} Tage übersprungen`, 'success');
}
// ============================================
// BULK EDIT
// ============================================
/**
* Toggle bulk edit mode
*/
function toggleBulkEditMode() {
bulkEditMode = !bulkEditMode;
selectedEntries.clear();
const bulkEditBar = document.getElementById('bulkEditBar');
const checkboxHeader = document.getElementById('checkboxHeader');
const toggleBtn = document.getElementById('btnToggleBulkEdit');
if (bulkEditMode) {
bulkEditBar.classList.remove('hidden');
checkboxHeader.classList.remove('hidden');
toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 font-medium shadow-sm hover:shadow';
toggleBtn.title = 'Mehrfachauswahl deaktivieren';
} else {
bulkEditBar.classList.add('hidden');
checkboxHeader.classList.add('hidden');
toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium shadow-sm hover:shadow';
toggleBtn.title = 'Mehrfachauswahl aktivieren';
}
// Reload view to show/hide checkboxes
reloadView();
}
/**
* Toggle entry selection
*/
function toggleEntrySelection(id) {
if (selectedEntries.has(id)) {
selectedEntries.delete(id);
} else {
selectedEntries.add(id);
}
updateSelectedCount();
updateCheckboxes();
}
/**
* Toggle empty day selection (for dates without entries)
*/
function toggleEmptyDaySelection(date) {
if (selectedEntries.has(date)) {
selectedEntries.delete(date);
} else {
selectedEntries.add(date);
}
updateSelectedCount();
updateCheckboxes();
}
/**
* Select all visible entries and empty days
*/
function selectAllEntries() {
// Select existing entries
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
const id = parseInt(checkbox.dataset.id);
selectedEntries.add(id);
checkbox.checked = true;
});
// Select empty days
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
const date = checkbox.dataset.date;
selectedEntries.add(date);
checkbox.checked = true;
});
updateSelectedCount();
}
/**
* Deselect all entries
*/
function deselectAllEntries() {
selectedEntries.clear();
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
updateSelectedCount();
}
/**
* Update selected count display
*/
function updateSelectedCount() {
document.getElementById('selectedCount').textContent = `${selectedEntries.size} ausgewählt`;
// Update master checkbox state
const masterCheckbox = document.getElementById('masterCheckbox');
const allCheckboxes = document.querySelectorAll('.entry-checkbox, .empty-day-checkbox');
if (allCheckboxes.length === 0) {
masterCheckbox.checked = false;
masterCheckbox.indeterminate = false;
} else if (selectedEntries.size === allCheckboxes.length) {
masterCheckbox.checked = true;
masterCheckbox.indeterminate = false;
} else if (selectedEntries.size > 0) {
masterCheckbox.checked = false;
masterCheckbox.indeterminate = true;
} else {
masterCheckbox.checked = false;
masterCheckbox.indeterminate = false;
}
}
/**
* Update checkbox states
*/
function updateCheckboxes() {
// Update entry checkboxes (ID-based)
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
const id = parseInt(checkbox.dataset.id);
checkbox.checked = selectedEntries.has(id);
});
// Update empty day checkboxes (date-based)
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
const date = checkbox.dataset.date;
checkbox.checked = selectedEntries.has(date);
});
updateSelectedCount();
}
/**
* Handle master checkbox toggle
*/
function handleMasterCheckboxToggle() {
const masterCheckbox = document.getElementById('masterCheckbox');
if (masterCheckbox.checked) {
selectAllEntries();
} else {
deselectAllEntries();
}
}
/**
* Bulk set location to office
*/
async function bulkSetLocation(location) {
if (selectedEntries.size === 0) {
showNotification('Keine Einträge ausgewählt', 'error');
return;
}
const locationText = location === 'home' ? 'Home Office' : 'Präsenz';
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge auf "${locationText}" setzen?`)) {
return;
}
let updated = 0;
const entries = await fetchEntries();
for (const id of selectedEntries) {
const entry = entries.find(e => e.id === id);
if (entry) {
// Convert date from YYYY-MM-DD to DD.MM.YYYY for updateEntry
const formattedDate = formatDateDisplay(entry.date);
const success = await updateEntry(id, formattedDate, entry.startTime, entry.endTime, entry.pauseMinutes, location);
if (success) {
updated++;
}
}
}
selectedEntries.clear();
updateSelectedCount(); // Update counter to 0
await reloadView();
showNotification(`${updated} Eintrag/Einträge aktualisiert`, 'success');
toggleBulkEditMode(); // Close bulk edit mode
}
/**
* Bulk delete entries
*/
async function bulkDeleteEntries() {
if (selectedEntries.size === 0) {
showNotification('Keine Einträge ausgewählt', 'error');
return;
}
if (!confirm(`Möchten Sie wirklich ${selectedEntries.size} Eintrag/Einträge löschen?`)) {
return;
}
let deleted = 0;
for (const id of selectedEntries) {
const success = await deleteEntry(id);
if (success) {
deleted++;
}
}
selectedEntries.clear();
updateSelectedCount(); // Update counter to 0
await reloadView();
showNotification(`${deleted} Eintrag/Einträge gelöscht`, 'success');
toggleBulkEditMode(); // Close bulk edit mode
}
/**
* Generate PDF with common template
* @param {Object} options - PDF generation options
*/
async function generatePDF(options) {
const {
title = 'Zeiterfassung',
subtitle,
tableData,
statistics,
additionalInfo = {},
fileName
} = options;
const { targetHours, totalNetHours, balance } = statistics;
const { vacationDays = 0, flextimeDays = 0 } = additionalInfo;
// Get employee data from settings
const employeeName = await getSetting('employeeName') || '';
const employeeId = await getSetting('employeeId') || '';
// Get jsPDF from window
const { jsPDF } = window.jspdf;
// Create PDF
const doc = new jsPDF('p', 'mm', 'a4');
// Header with statistics
doc.setFillColor(15, 23, 42);
doc.rect(0, 0, 210, 35, 'F');
// Title and subtitle
doc.setTextColor(255, 255, 255);
doc.setFontSize(16);
doc.setFont(undefined, 'bold');
doc.text(title, 15, 12, { align: 'left' });
doc.setFontSize(11);
doc.setFont(undefined, 'normal');
doc.text(subtitle, 195, 12, { align: 'right' });
// Employee info in second line
if (employeeName || employeeId) {
doc.setFontSize(8);
doc.setTextColor(200, 200, 200);
let employeeInfo = '';
if (employeeName) employeeInfo += employeeName;
if (employeeId) {
if (employeeInfo) employeeInfo += ' | ';
employeeInfo += `Personal-Nr. ${employeeId}`;
}
doc.text(employeeInfo, 15, 19, { align: 'left' });
}
// Statistics - three columns in one line
const statsY = employeeName || employeeId ? 28 : 22;
// Soll
doc.setTextColor(180, 180, 180);
doc.setFontSize(7);
doc.text('SOLL-STUNDEN', 40, statsY - 3, { align: 'center' });
doc.setTextColor(255, 255, 255);
doc.setFontSize(11);
doc.setFont(undefined, 'bold');
doc.text(`${targetHours.toFixed(1)}h`, 40, statsY + 3, { align: 'center' });
// Ist
doc.setTextColor(180, 180, 180);
doc.setFontSize(7);
doc.setFont(undefined, 'normal');
doc.text('IST-STUNDEN', 105, statsY - 3, { align: 'center' });
doc.setTextColor(255, 255, 255);
doc.setFontSize(11);
doc.setFont(undefined, 'bold');
doc.text(`${totalNetHours.toFixed(1)}h`, 105, statsY + 3, { align: 'center' });
// Saldo
doc.setTextColor(180, 180, 180);
doc.setFontSize(7);
doc.setFont(undefined, 'normal');
doc.text('SALDO', 170, statsY - 3, { align: 'center' });
if (balance >= 0) {
doc.setTextColor(34, 197, 94);
} else {
doc.setTextColor(239, 68, 68);
}
doc.setFontSize(11);
doc.setFont(undefined, 'bold');
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
// Additional info if needed (small, far right)
if (vacationDays > 0 || flextimeDays > 0) {
doc.setTextColor(150, 150, 150);
doc.setFontSize(7);
doc.setFont(undefined, 'normal');
let infoText = '';
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
if (flextimeDays > 0) {
if (infoText) infoText += ' ';
infoText += `Gleitzeit: ${flextimeDays}`;
}
doc.text(infoText, 195, statsY + 3, { align: 'right' });
}
// Table starts after header
let yPos = 37;
// Generate table
doc.autoTable({
startY: yPos,
head: [['Datum', 'Tag', 'Beginn', 'Ende', 'Pause', 'Typ', 'Netto', 'Abw.']],
body: tableData,
theme: 'grid',
tableWidth: 'auto',
headStyles: {
fillColor: [30, 41, 59],
textColor: [255, 255, 255],
fontSize: 9,
fontStyle: 'bold',
halign: 'center',
cellPadding: 2.5,
minCellHeight: 7
},
bodyStyles: {
fillColor: [248, 250, 252],
textColor: [15, 23, 42],
fontSize: 8,
cellPadding: 2,
minCellHeight: 6
},
alternateRowStyles: {
fillColor: [241, 245, 249]
},
columnStyles: {
0: { halign: 'center', cellWidth: 24 }, // Datum
1: { halign: 'center', cellWidth: 14 }, // Tag
2: { halign: 'center', cellWidth: 18 }, // Beginn
3: { halign: 'center', cellWidth: 18 }, // Ende
4: { halign: 'center', cellWidth: 18 }, // Pause
5: { halign: 'center', cellWidth: 26 }, // Ort
6: { halign: 'center', cellWidth: 18 }, // Netto
7: { halign: 'center', cellWidth: 18 } // Abweichung
},
didParseCell: function(data) {
if (data.column.index === 7 && data.section === 'body') {
const value = data.cell.raw;
if (value.startsWith('+')) {
data.cell.styles.textColor = [34, 197, 94];
data.cell.styles.fontStyle = 'bold';
} else if (value.startsWith('-') && value !== '-') {
data.cell.styles.textColor = [239, 68, 68];
data.cell.styles.fontStyle = 'bold';
}
}
},
margin: { left: 20, right: 20 } // Smaller margins for wider table
});
// Footer
const finalY = doc.lastAutoTable.finalY || yPos + 50;
if (finalY < 270) {
doc.setTextColor(156, 163, 175);
doc.setFontSize(8);
doc.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 105, 285, { align: 'center' });
}
// Save PDF
doc.save(fileName);
}
/**
* Bulk export selected entries as PDF
*/
async function bulkExportPDF() {
if (selectedEntries.size === 0) {
showNotification('Keine Einträge ausgewählt', 'error');
return;
}
try {
const { jsPDF } = window.jspdf;
// Get all entries and filter by selected IDs
const allEntries = await fetchEntries();
const selectedEntriesData = allEntries.filter(e => selectedEntries.has(e.id));
if (selectedEntriesData.length === 0) {
showNotification('Keine Einträge zum Exportieren gefunden', 'error');
return;
}
// Sort by date
selectedEntriesData.sort((a, b) => new Date(a.date) - new Date(b.date));
// Get date range (parse dates correctly to avoid timezone issues)
const firstDateParts = selectedEntriesData[0].date.split('-');
const firstDate = new Date(parseInt(firstDateParts[0]), parseInt(firstDateParts[1]) - 1, parseInt(firstDateParts[2]));
const lastDateParts = selectedEntriesData[selectedEntriesData.length - 1].date.split('-');
const lastDate = new Date(parseInt(lastDateParts[0]), parseInt(lastDateParts[1]) - 1, parseInt(lastDateParts[2]));
const dateRange = selectedEntriesData[0].date === selectedEntriesData[selectedEntriesData.length - 1].date ?
formatDateDisplay(selectedEntriesData[0].date) :
`${formatDateDisplay(selectedEntriesData[0].date)} - ${formatDateDisplay(selectedEntriesData[selectedEntriesData.length - 1].date)}`;
// Get employee data from settings
const employeeName = await getSetting('employeeName') || '';
const employeeId = await getSetting('employeeId') || '';
// Calculate statistics based only on selected entries
const today = new Date();
let workdaysPassed = 0;
// Get unique dates from selected entries
const selectedDates = new Set(selectedEntriesData.map(e => e.date));
// Count vacation days from selected entries
const vacationDaysSet = new Set(
selectedEntriesData
.filter(e => e.entryType === 'vacation')
.map(e => e.date)
);
// Count flextime days from selected entries
const flextimeDaysSet = new Set(
selectedEntriesData
.filter(e => e.entryType === 'flextime')
.map(e => e.date)
);
// Count workdays based on selected entries only
selectedDates.forEach(dateISO => {
// Parse date correctly to avoid timezone issues
const [year, month, day] = dateISO.split('-').map(Number);
const dateObj = new Date(year, month - 1, day);
const isVacation = vacationDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) {
// Normal workday
if (dateObj <= today) {
workdaysPassed++;
}
} else if (isFlextime && isWeekendHoliday) {
// Flextime on weekend/holiday counts as workday
if (dateObj <= today) {
workdaysPassed++;
}
}
// Vacation days are excluded from workday count
});
// Calculate total hours and days
let totalNetHours = 0;
let vacationDays = 0;
let flextimeDays = 0;
let workEntriesCount = 0;
selectedEntriesData.forEach(entry => {
const entryDate = new Date(entry.date);
if (entryDate <= today) {
if (!entry.entryType || entry.entryType === 'work') {
totalNetHours += entry.netHours;
workEntriesCount++;
} else if (entry.entryType === 'vacation') {
vacationDays++;
totalNetHours += entry.netHours;
} else if (entry.entryType === 'flextime') {
flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday
// (otherwise it's already counted as workday hours)
const dateObj = new Date(entry.date);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (isWeekendHoliday) {
totalNetHours += entry.netHours;
}
// If flextime on regular workday, don't add hours (already in work entries)
}
}
});
const targetHours = workdaysPassed * 8;
const balance = totalNetHours - targetHours;
// Build table data
const allDaysData = [];
const entriesMap = new Map(selectedEntriesData.map(e => [e.date, e]));
let currentDate = new Date(firstDate);
while (currentDate <= lastDate) {
const yearStr = currentDate.getFullYear();
const monthStr = String(currentDate.getMonth() + 1).padStart(2, '0');
const dayStr = String(currentDate.getDate()).padStart(2, '0');
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
const formattedDate = formatDateDisplay(dateISO);
const weekday = currentDate.toLocaleDateString('de-DE', { weekday: 'short' });
const isWeekendHoliday = isWeekendOrHoliday(currentDate);
const entry = entriesMap.get(dateISO);
if (entry) {
// Entry exists - use actual data
const deviation = entry.netHours - 8.0;
let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h';
let locationText = '';
let startTime = entry.startTime;
let endTime = entry.endTime;
let pauseText = entry.pauseMinutes + ' min';
let netHoursText = entry.netHours.toFixed(2) + 'h';
if (entry.entryType === 'vacation') {
locationText = 'Urlaub';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') {
locationText = 'Gleittag';
startTime = '-';
endTime = '-';
pauseText = '-';
} else {
locationText = entry.location === 'home' ? 'Home' : 'Office';
}
allDaysData.push([
formattedDate,
weekday,
startTime,
endTime,
pauseText,
locationText,
netHoursText,
deviationStr
]);
} else if (isWeekendHoliday) {
// Weekend or holiday without entry
const holidayName = getHolidayName(currentDate);
const dayOfWeek = currentDate.getDay();
const isWeekendDay = dayOfWeek === 0 || dayOfWeek === 6;
let dayType = '';
if (holidayName) {
dayType = 'Feiertag';
} else if (isWeekendDay) {
dayType = 'Wochenende';
}
allDaysData.push([
formattedDate,
weekday,
'-',
'-',
'-',
dayType,
'-',
'-'
]);
}
// Skip regular workdays without entries
currentDate.setDate(currentDate.getDate() + 1);
}
// Generate PDF using common template
const fileName = `Zeiterfassung_${selectedEntriesData[0].date}_${selectedEntriesData[selectedEntriesData.length - 1].date}.pdf`;
await generatePDF({
title: 'Zeiterfassung',
subtitle: dateRange,
tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance },
additionalInfo: { vacationDays, flextimeDays },
fileName
});
showNotification(`PDF mit ${allDaysData.length} Tag(en) erstellt`, 'success');
} catch (error) {
console.error('Error exporting PDF:', error);
showNotification('Fehler beim PDF-Export', 'error');
}
}
/**
* Bulk set vacation entries
*/
async function bulkSetVacation() {
if (selectedEntries.size === 0) {
showNotification('Keine Einträge ausgewählt', 'error');
return;
}
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Urlaub eintragen?`)) {
return;
}
let created = 0;
let updated = 0;
let skipped = 0;
const entries = await fetchEntries();
for (const item of selectedEntries) {
// Check if it's an ID (number) or date (string)
if (typeof item === 'number') {
// Update existing entry to vacation
const entry = entries.find(e => e.id === item);
if (entry) {
// Skip weekends/holidays
const dateObj = new Date(entry.date);
if (isWeekendOrHoliday(dateObj)) {
skipped++;
continue;
}
try {
const response = await fetch(`/api/entries/${item}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: entry.date,
entryType: 'vacation'
})
});
if (response.ok) {
updated++;
}
} catch (error) {
console.error('Error updating entry:', error);
}
}
} else {
// Create new vacation entry for empty day (item is date string)
// Skip weekends/holidays
const dateObj = new Date(item);
if (isWeekendOrHoliday(dateObj)) {
skipped++;
continue;
}
try {
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: item,
entryType: 'vacation'
})
});
if (response.ok) {
created++;
}
} catch (error) {
console.error('Error creating vacation entry:', error);
}
}
}
selectedEntries.clear();
updateSelectedCount(); // Update counter to 0
await reloadView();
const message = skipped > 0
? `${created} Urlaub neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen`
: `${created} Urlaub neu eingetragen, ${updated} Einträge geändert`;
showNotification(message, 'success');
toggleBulkEditMode(); // Close bulk edit mode
}
/**
* Bulk set flextime entries
*/
async function bulkSetFlextime() {
if (selectedEntries.size === 0) {
showNotification('Keine Einträge ausgewählt', 'error');
return;
}
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Gleitzeit eintragen?`)) {
return;
}
let created = 0;
let updated = 0;
let skipped = 0;
const entries = await fetchEntries();
for (const item of selectedEntries) {
// Check if it's an ID (number) or date (string)
if (typeof item === 'number') {
// Update existing entry to flextime
const entry = entries.find(e => e.id === item);
if (entry) {
// Skip weekends/holidays
const dateObj = new Date(entry.date);
if (isWeekendOrHoliday(dateObj)) {
skipped++;
continue;
}
try {
const response = await fetch(`/api/entries/${item}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: entry.date,
entryType: 'flextime'
})
});
if (response.ok) {
updated++;
}
} catch (error) {
console.error('Error updating entry:', error);
}
}
} else {
// Create new flextime entry for empty day (item is date string)
// Skip weekends/holidays
const dateObj = new Date(item);
if (isWeekendOrHoliday(dateObj)) {
skipped++;
continue;
}
try {
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: item,
entryType: 'flextime'
})
});
if (response.ok) {
created++;
}
} catch (error) {
console.error('Error creating flextime entry:', error);
}
}
}
selectedEntries.clear();
updateSelectedCount(); // Update counter to 0
await reloadView();
const message = skipped > 0
? `${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen`
: `${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert`;
showNotification(message, 'success');
toggleBulkEditMode(); // Close bulk edit mode
}
// ============================================
// BUNDESLAND / SETTINGS
// ============================================
/**
* Handle Bundesland change
*/
async function handleBundeslandChange(event) {
const newBundesland = event.target.value;
const oldBundesland = currentBundesland;
// Check for conflicts with existing entries
const entries = await fetchEntries();
const conflicts = [];
// Get old and new holidays for comparison
const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1];
const oldHolidays = new Set();
const newHolidays = new Set();
years.forEach(year => {
getPublicHolidays(year, oldBundesland).forEach(h => {
oldHolidays.add(h.date.toISOString().split('T')[0]);
});
getPublicHolidays(year, newBundesland).forEach(h => {
newHolidays.add(h.date.toISOString().split('T')[0]);
});
});
// Find dates that are holidays in new state but not in old state, and have entries
entries.forEach(entry => {
if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) {
const dateObj = new Date(entry.date);
// Temporarily set to new bundesland to get holiday name
const tempBundesland = currentBundesland;
currentBundesland = newBundesland;
const holidayName = getHolidayName(dateObj);
currentBundesland = tempBundesland;
conflicts.push({
date: entry.date,
displayDate: formatDateDisplay(entry.date),
holidayName: holidayName
});
}
});
// Warn user if conflicts exist
if (conflicts.length > 0) {
const conflictList = conflicts.map(c => `${c.displayDate} (${c.holidayName})`).join('\n');
const message = `Achtung!\n\nDie folgenden Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nMöchten Sie fortfahren? Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.`;
if (!confirm(message)) {
// Revert selection
event.target.value = oldBundesland;
return;
}
}
// Update state and save
currentBundesland = newBundesland;
await setSetting('bundesland', newBundesland);
// Reload view to show updated holidays
await reloadView();
const bundeslandNames = {
'BW': 'Baden-Württemberg',
'BY': 'Bayern',
'BE': 'Berlin',
'BB': 'Brandenburg',
'HB': 'Bremen',
'HH': 'Hamburg',
'HE': 'Hessen',
'MV': 'Mecklenburg-Vorpommern',
'NI': 'Niedersachsen',
'NW': 'Nordrhein-Westfalen',
'RP': 'Rheinland-Pfalz',
'SL': 'Saarland',
'SN': 'Sachsen',
'ST': 'Sachsen-Anhalt',
'SH': 'Schleswig-Holstein',
'TH': 'Thüringen'
};
showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success');
}
/**
* Load saved settings
*/
async function loadSettings() {
const savedBundesland = await getSetting('bundesland');
if (savedBundesland) {
currentBundesland = savedBundesland;
document.getElementById('bundeslandSelect').value = savedBundesland;
}
const savedVacationDays = await getSetting('vacationDays');
if (savedVacationDays) {
totalVacationDays = parseInt(savedVacationDays);
document.getElementById('vacationDaysInput').value = totalVacationDays;
}
const savedEmployeeName = await getSetting('employeeName');
if (savedEmployeeName) {
document.getElementById('employeeName').value = savedEmployeeName;
}
const savedEmployeeId = await getSetting('employeeId');
if (savedEmployeeId) {
document.getElementById('employeeId').value = savedEmployeeId;
}
}
/**
* Handle vacation days input change
*/
async function handleVacationDaysChange(event) {
const newValue = parseInt(event.target.value);
if (isNaN(newValue) || newValue < 0 || newValue > 50) {
showNotification('Bitte geben Sie eine gültige Anzahl (0-50) ein', 'error');
event.target.value = totalVacationDays;
return;
}
totalVacationDays = newValue;
await setSetting('vacationDays', newValue.toString());
await updateVacationStatistics();
showNotification(`✓ Urlaubstage auf ${newValue} pro Jahr gesetzt`, 'success');
}
// ============================================
// INLINE EDITING
// ============================================
/**
* Attach inline edit listeners to all editable cells
*/
function attachInlineEditListeners() {
document.querySelectorAll('.editable-cell').forEach(cell => {
cell.addEventListener('click', handleCellClick);
});
}
/**
* Handle cell click for inline editing
*/
function handleCellClick(e) {
const cell = e.target;
// Prevent editing if already editing
if (cell.classList.contains('editing')) {
return;
}
const field = cell.dataset.field;
const id = parseInt(cell.dataset.id);
const currentValue = cell.dataset.value;
// Create input
cell.classList.add('editing');
const originalContent = cell.innerHTML;
let input;
if (field === 'pauseMinutes') {
input = document.createElement('input');
input.type = 'number';
input.min = '0';
input.step = '1';
input.className = 'cell-input';
input.value = currentValue;
} else {
// Time fields
input = document.createElement('input');
input.type = 'text';
input.className = 'cell-input';
input.value = currentValue;
input.placeholder = 'HH:MM';
}
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
// Save on blur or Enter
const saveEdit = async () => {
const newValue = input.value.trim();
if (newValue === currentValue) {
// No change
cell.classList.remove('editing');
cell.innerHTML = originalContent;
return;
}
// Validate
if (field !== 'pauseMinutes') {
// Validate time format
if (!/^\d{1,2}:\d{2}$/.test(newValue)) {
showNotification('Ungültiges Format. Bitte HH:MM verwenden.', 'error');
cell.classList.remove('editing');
cell.innerHTML = originalContent;
return;
}
}
// Get current entry data
const row = cell.closest('tr');
const entryId = parseInt(row.dataset.id);
// Find date from row (skip weekday column, get actual date column)
const dateCells = row.querySelectorAll('td');
const dateText = dateCells[1].textContent; // Second column is date
// Get all values from row
const cells = row.querySelectorAll('.editable-cell');
const values = {};
cells.forEach(c => {
const f = c.dataset.field;
if (c === cell) {
values[f] = newValue;
} else {
values[f] = c.dataset.value;
}
});
// If startTime or endTime changed, set pauseMinutes to null for auto-calculation
// If pauseMinutes was edited, keep the manual value
let pauseToSend = values.pauseMinutes;
if (field === 'startTime' || field === 'endTime') {
pauseToSend = null; // Trigger auto-calculation on server
}
// Update via API
const success = await updateEntry(
entryId,
dateText,
values.startTime,
values.endTime,
pauseToSend
);
if (success) {
// Update cell
cell.dataset.value = newValue;
cell.classList.remove('editing');
cell.textContent = newValue;
// Reload to update calculated values
loadMonthlyView();
} else {
// Revert on error
cell.classList.remove('editing');
cell.innerHTML = originalContent;
}
};
input.addEventListener('blur', saveEdit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveEdit();
} else if (e.key === 'Escape') {
cell.classList.remove('editing');
cell.innerHTML = originalContent;
}
});
}
/**
* Handle filter button click
*/
function handleFilter() {
const fromValue = document.getElementById('filterFrom').value;
const toValue = document.getElementById('filterTo').value;
const fromDate = fromValue ? formatDateISO(fromValue) : null;
const toDate = toValue ? formatDateISO(toValue) : null;
currentView = 'filter';
currentFilterFrom = fromDate;
currentFilterTo = toDate;
hideMonthNavigation();
loadEntries(fromDate, toDate);
}
/**
* Handle clear filter button click
*/
function handleClearFilter() {
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
currentView = 'monthly';
currentFilterFrom = null;
currentFilterTo = null;
loadMonthlyView();
}
/**
* Handle export button click
*/
async function handleExport(onlyDeviations = false) {
const fromValue = document.getElementById('filterFrom').value;
const toValue = document.getElementById('filterTo').value;
// Validate that both dates are set
if (!fromValue || !toValue) {
showNotification('Bitte beide Datumsfelder ausfüllen', 'error');
return;
}
const fromDate = formatDateISO(fromValue);
const toDate = formatDateISO(toValue);
try {
// Fetch entries for the date range
let entries = await fetchEntries(fromDate, toDate);
// Filter entries if only deviations are requested
if (onlyDeviations) {
entries = entries.filter(entry => Math.abs(entry.netHours - 8.0) > 0.01);
}
// Check if there are any entries
if (entries.length === 0) {
showNotification(
onlyDeviations ?
'Keine Abweichungen vom 8h-Standard im gewählten Zeitraum.' :
'Keine Einträge im gewählten Zeitraum.',
'error'
);
return;
}
// Create CSV content
const csvHeader = 'Datum;Beginn;Ende;Pause (min);Netto (h);Arbeitsort;Abweichung (h)\n';
const csvRows = entries.map(entry => {
const deviation = entry.netHours - 8.0;
const deviationStr = (deviation >= 0 ? '+' : '') + deviation.toFixed(2);
const locationText = entry.location === 'home' ? 'Home' : 'Büro';
return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${locationText};${deviationStr}`;
}).join('\n');
const csvContent = csvHeader + csvRows;
// Create download link
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const fileName = onlyDeviations ?
`zeiterfassung_abweichungen_${fromDate}_${toDate}.csv` :
`zeiterfassung_${fromDate}_${toDate}.csv`;
link.setAttribute('href', url);
link.setAttribute('download', fileName);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const message = onlyDeviations ?
`${entries.length} Abweichung(en) exportiert` :
`${entries.length} Eintrag/Einträge exportiert`;
showNotification(message, 'success');
} catch (error) {
console.error('Error exporting CSV:', error);
showNotification('Fehler beim CSV-Export', 'error');
}
}
/**
* Export current month as PDF
*/
async function handleExportPDF() {
try {
const { jsPDF } = window.jspdf;
// Get current month data
const year = displayYear;
const month = displayMonth + 1; // displayMonth is 0-indexed, we need 1-12
const monthName = new Date(year, displayMonth).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
// Get first and last day of month in YYYY-MM-DD format
const currentYear = year;
const currentMonth = displayMonth; // Keep 0-indexed for Date constructor
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
const fromDate = `${year}-${String(month).padStart(2, '0')}-01`;
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
// Fetch entries
const entries = await fetchEntries(fromDate, toDate);
if (entries.length === 0) {
showNotification('Keine Einträge im Monat vorhanden', 'error');
return;
}
// Calculate statistics
const today = new Date();
today.setHours(23, 59, 59, 999); // Set to end of day to include today
// Find the last entry date to calculate workdays up to (parse correctly to avoid timezone issues)
let lastEntryDate = today;
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.date.localeCompare(a.date));
const lastDateStr = sortedEntries[0].date; // "2025-10-22"
const [year, month, day] = lastDateStr.split('-').map(Number);
lastEntryDate = new Date(year, month - 1, day, 23, 59, 59, 999);
}
// Count workdays (excluding weekends and holidays, up to last entry or today, whichever is later)
const countUntil = lastEntryDate > today ? lastEntryDate : today;
let workdaysPassed = 0;
let totalWorkdaysInMonth = 0;
// Count vacation days to exclude from workdays
const vacationDaysSet = new Set(
entries
.filter(e => e.entryType === 'vacation')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDaysSet = new Set(
entries
.filter(e => e.entryType === 'flextime')
.map(e => e.date)
);
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
const yearStr = dateObj.getFullYear();
const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
const isVacation = vacationDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) {
// Normal workday (excluding vacation days)
totalWorkdaysInMonth++;
if (dateObj <= countUntil) {
workdaysPassed++;
}
} else if (isFlextime && isWeekendHoliday) {
// Flextime on weekend/holiday counts as additional workday
totalWorkdaysInMonth++;
if (new Date(dateISO) <= countUntil) {
workdaysPassed++;
}
}
// Vacation days are excluded from all counts
}
let totalNetHours = 0;
let vacationDays = 0;
let flextimeDays = 0;
let workEntriesCount = 0;
// Create map of entries by date for proper handling
const entriesByDate = new Map();
entries.forEach(entry => {
if (!entriesByDate.has(entry.date)) {
entriesByDate.set(entry.date, []);
}
entriesByDate.get(entry.date).push(entry);
});
entries.forEach(entry => {
const entryDate = new Date(entry.date);
if (entryDate <= countUntil) {
if (!entry.entryType || entry.entryType === 'work') {
totalNetHours += entry.netHours;
workEntriesCount++;
} else if (entry.entryType === 'vacation') {
vacationDays++;
// Vacation hours are already included in netHours (8h per day typically)
totalNetHours += entry.netHours;
} else if (entry.entryType === 'flextime') {
flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday
// (otherwise it's already counted as workday hours)
const dateObj = new Date(entry.date);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (isWeekendHoliday) {
totalNetHours += entry.netHours;
}
// If flextime on regular workday, hours are already counted in work entries
}
}
});
// Add running timer if active (only for current month)
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
let runningTimerHours = 0;
if (timerStartTime && isCurrentMonth) {
const now = Date.now();
const elapsed = now - timerStartTime;
const hours = elapsed / (1000 * 60 * 60);
const netHours = Math.max(0, hours - 0.5); // Subtract 30 min pause
runningTimerHours = netHours;
totalNetHours += netHours;
}
const targetHours = workdaysPassed * 8;
const monthBalance = totalNetHours - targetHours;
// Get previous balance
const previousBalance = await calculatePreviousBalance(year, month);
const totalBalance = monthBalance + previousBalance;
// Build table data
const allDaysData = [];
const entriesMap = new Map(entries.map(e => [e.date, e]));
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
const yearStr = dateObj.getFullYear();
const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
const formattedDate = formatDateDisplay(dateISO);
const weekday = dateObj.toLocaleDateString('de-DE', { weekday: 'short' });
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
const entry = entriesMap.get(dateISO);
if (entry) {
// Entry exists - use actual data
const deviation = entry.netHours - 8.0;
let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h';
let locationText = '';
let startTime = entry.startTime;
let endTime = entry.endTime;
let pauseText = entry.pauseMinutes + ' min';
let netHoursText = entry.netHours.toFixed(2) + 'h';
if (entry.entryType === 'vacation') {
locationText = 'Urlaub';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') {
locationText = 'Gleittag';
startTime = '-';
endTime = '-';
pauseText = '-';
} else {
locationText = entry.location === 'home' ? 'Home' : 'Office';
}
allDaysData.push([
formattedDate,
weekday,
startTime,
endTime,
pauseText,
locationText,
netHoursText,
deviationStr
]);
} else if (isWeekendHoliday) {
// Weekend or holiday without entry
const isWeekendDay = isWeekend(dateObj);
const isHoliday = isPublicHoliday(dateObj);
let dayType = '';
if (isWeekendDay) {
dayType = 'Wochenende';
} else if (isHoliday) {
dayType = 'Feiertag';
}
allDaysData.push([
formattedDate,
weekday,
'-',
'-',
'-',
dayType,
'-',
'-'
]);
}
// Skip regular workdays without entries
}
// Generate PDF using common template
const fileName = `Zeiterfassung_${monthName.replace(' ', '_')}.pdf`;
await generatePDF({
title: 'Zeiterfassung',
subtitle: monthName,
tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance: monthBalance },
additionalInfo: { vacationDays, flextimeDays },
fileName
});
showNotification(`PDF für ${monthName} erstellt`, 'success');
} catch (error) {
console.error('Error exporting PDF:', error);
showNotification('Fehler beim PDF-Export', 'error');
}
}
/**
* Export entire database
*/
async function exportDatabase() {
try {
// Fetch all data
const entries = await fetchEntries();
const settings = {
employeeName: await getSetting('employeeName') || '',
employeeId: await getSetting('employeeId') || '',
bundesland: await getSetting('bundesland') || 'NW',
vacationDaysPerYear: await getSetting('vacationDaysPerYear') || 30
};
// Create export object
const exportData = {
version: '1.0',
exportDate: new Date().toISOString(),
entries: entries,
settings: settings
};
// Convert to JSON
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `zeiterfassung_backup_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Datenbank exportiert: ${entries.length} Einträge`, 'success');
} catch (error) {
console.error('Error exporting database:', error);
showNotification('Fehler beim Exportieren der Datenbank', 'error');
}
}
/**
* Import entire database
*/
async function importDatabase(file) {
try {
const text = await file.text();
const importData = JSON.parse(text);
// Validate data structure
if (!importData.entries || !Array.isArray(importData.entries)) {
throw new Error('Ungültiges Datenbankformat');
}
// Confirm before overwriting
const confirmed = confirm(
`Möchten Sie die Datenbank wirklich importieren?\n\n` +
`Dies wird alle ${importData.entries.length} Einträge importieren und vorhandene Daten überschreiben.\n\n` +
`Export-Datum: ${new Date(importData.exportDate).toLocaleString('de-DE')}`
);
if (!confirmed) {
return;
}
// Delete all existing entries
const existingEntries = await fetchEntries();
for (const entry of existingEntries) {
await fetch(`/api/entries/${entry.id}`, { method: 'DELETE' });
}
// Import entries
let imported = 0;
for (const entry of importData.entries) {
try {
// Remove ID to create new entries
const { id, ...entryData } = entry;
await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entryData)
});
imported++;
} catch (err) {
console.error('Error importing entry:', err);
}
}
// Import settings
if (importData.settings) {
await setSetting('employeeName', importData.settings.employeeName || '');
await setSetting('employeeId', importData.settings.employeeId || '');
await setSetting('bundesland', importData.settings.bundesland || 'NW');
await setSetting('vacationDaysPerYear', importData.settings.vacationDaysPerYear || 30);
await loadSettings(); // Reload settings to UI
}
// Reload view
await reloadView();
showNotification(`Datenbank importiert: ${imported} Einträge`, 'success');
} catch (error) {
console.error('Error importing database:', error);
showNotification('Fehler beim Importieren der Datenbank: ' + error.message, 'error');
}
}
/**
* Show a temporary notification
*/
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' :
'bg-blue-600'
}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(() => document.body.removeChild(notification), 500);
}, 3000);
}
// ============================================
// FLATPICKR INITIALIZATION
// ============================================
/**
* Initialize all Flatpickr instances with optimized settings
*/
function initializeFlatpickr() {
// German locale
flatpickr.localize(flatpickr.l10ns.de);
// Date pickers - using DD.MM.YYYY format
const dateConfig = {
dateFormat: 'd.m.Y',
locale: 'de',
allowInput: true
};
filterFromPicker = flatpickr('#filterFrom', dateConfig);
filterToPicker = flatpickr('#filterTo', dateConfig);
datePicker = flatpickr('#modalDate', dateConfig);
// Time pickers with enhanced configuration
// For a better "tumbler" experience on desktop, we use:
// - enableTime: true
// - noCalendar: true (time only)
// - time_24hr: true (24-hour format)
// - minuteIncrement: 15 (15-minute steps)
// - Mobile browsers will show native time picker automatically
const timeConfig = {
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
time_24hr: true,
minuteIncrement: 15,
allowInput: true,
locale: 'de',
// Mobile devices will use native picker which has wheel/tumbler interface
// Desktop will use Flatpickr's time picker with improved UX
static: false,
// When opening the picker, use the current value in the input field
onOpen: function(selectedDates, dateStr, instance) {
const inputValue = instance.input.value;
if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) {
instance.setDate(inputValue, false);
}
}
};
startTimePicker = flatpickr('#modalStartTime', timeConfig);
endTimePicker = flatpickr('#modalEndTime', timeConfig);
// Initialize manual start time picker
manualStartTimePicker = flatpickr('#manualTimeInput', {
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
time_24hr: true,
minuteIncrement: 15,
allowInput: true,
locale: 'de',
defaultHour: 9,
defaultMinute: 0,
onOpen: function(selectedDates, dateStr, instance) {
const inputValue = instance.input.value;
if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) {
instance.setDate(inputValue, false);
}
}
});
}
/**
* Handle manual start time input
*/
async function handleManualStartTime(timeStr) {
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
// Parse the time
const [hours, minutes] = timeStr.split(':').map(Number);
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0);
// Check if entry for today already exists
const entries = await fetchEntries(today, today);
if (entries.length > 0) {
showNotification('Für heute existiert bereits ein Eintrag', 'error');
return;
}
// Create entry with same start and end time (indicates running)
const entry = await createEntry(formatDateDisplay(today), timeStr, timeStr, null);
if (!entry) {
return;
}
currentEntryId = entry.id;
timerStartTime = startDate.getTime();
timerStartTimeString = timeStr;
timerPausedDuration = 0;
isPaused = false;
// Update UI
const startBtn = document.getElementById('btnStartWork');
const stopBtn = document.getElementById('btnStopWork');
startBtn.disabled = true;
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
startBtn.classList.remove('hover:bg-green-700');
stopBtn.disabled = false;
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
stopBtn.classList.add('hover:bg-red-700');
document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr;
document.getElementById('timerStatus').classList.remove('cursor-pointer', 'underline', 'hover:text-blue-300');
document.getElementById('timerStatus').classList.add('cursor-default');
// Calculate elapsed time and check for active pauses
const elapsed = now.getTime() - startDate.getTime();
const elapsedSeconds = Math.floor(elapsed / 1000);
// Check if we're in a pause
const sixHoursSeconds = 6 * 60 * 60;
const nineHoursSeconds = 9 * 60 * 60;
const thirtyMinutes = 30 * 60;
const fifteenMinutes = 15 * 60;
// Check if in 6-hour pause (6h to 6h30m real time)
if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) {
isPaused = true;
pauseStartElapsed = sixHoursSeconds;
timerPausedDuration = 0;
const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds;
pauseEndTime = Date.now() + (remainingPause * 1000);
document.getElementById('timerStatus').textContent = `Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`;
// Schedule end of pause
pauseTimeout = setTimeout(() => {
timerPausedDuration = thirtyMinutes;
isPaused = false;
pauseStartElapsed = 0;
pauseEndTime = 0;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr;
}, remainingPause * 1000);
}
// Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time)
else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
isPaused = true;
pauseStartElapsed = nineHoursSeconds; // Work time when pause starts
timerPausedDuration = thirtyMinutes;
const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds;
pauseEndTime = Date.now() + (remainingPause * 1000);
document.getElementById('timerStatus').textContent = `Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`;
// Schedule end of pause
pauseTimeout = setTimeout(() => {
timerPausedDuration = thirtyMinutes + fifteenMinutes;
isPaused = false;
pauseStartElapsed = 0;
pauseEndTime = 0;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + timeStr;
}, remainingPause * 1000);
}
// Not in pause, but may have completed pauses
else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) {
// After first pause, before second pause
timerPausedDuration = thirtyMinutes;
} else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
// After both pauses
timerPausedDuration = thirtyMinutes + fifteenMinutes;
}
// Start timer interval
timerInterval = setInterval(updateTimer, 1000);
updateTimer(); // Immediate update
// Calculate offset for pause scheduling
schedulePausesWithOffset(elapsed);
// Reload view to show entry
await loadMonthlyView();
showNotification(`Timer gestartet ab ${timeStr}`, 'success');
}
// ============================================
// EVENT LISTENERS
// ============================================
function initializeEventListeners() {
// Auto-fill month button
document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth);
// PDF Export button
document.getElementById('btnExportPDF').addEventListener('click', handleExportPDF);
// Bulk edit toggle
document.getElementById('btnToggleBulkEdit').addEventListener('click', toggleBulkEditMode);
// Master checkbox
document.getElementById('masterCheckbox').addEventListener('change', handleMasterCheckboxToggle);
// Bulk edit actions
document.getElementById('btnSelectAll').addEventListener('click', selectAllEntries);
document.getElementById('btnDeselectAll').addEventListener('click', deselectAllEntries);
document.getElementById('btnBulkSetOffice').addEventListener('click', () => bulkSetLocation('office'));
document.getElementById('btnBulkSetHome').addEventListener('click', () => bulkSetLocation('home'));
document.getElementById('btnBulkSetVacation').addEventListener('click', bulkSetVacation);
document.getElementById('btnBulkSetFlextime').addEventListener('click', bulkSetFlextime);
document.getElementById('btnBulkDelete').addEventListener('click', bulkDeleteEntries);
document.getElementById('btnBulkExportPDF').addEventListener('click', bulkExportPDF);
// Cancel modal button
document.getElementById('btnCancelModal').addEventListener('click', closeModal);
// Location buttons
document.getElementById('btnLocationOffice').addEventListener('click', () => {
document.getElementById('modalLocation').value = 'office';
updateLocationButtons('office');
});
document.getElementById('btnLocationHome').addEventListener('click', () => {
document.getElementById('modalLocation').value = 'home';
updateLocationButtons('home');
});
// Form submission
document.getElementById('entryForm').addEventListener('submit', handleFormSubmit);
// Filter buttons
document.getElementById('btnFilter').addEventListener('click', handleFilter);
document.getElementById('btnClearFilter').addEventListener('click', handleClearFilter);
// Month navigation
document.getElementById('btnPrevMonth').addEventListener('click', handlePrevMonth);
document.getElementById('btnNextMonth').addEventListener('click', handleNextMonth);
// Export buttons
document.getElementById('btnExport').addEventListener('click', () => handleExport(false));
document.getElementById('btnExportDeviations').addEventListener('click', () => handleExport(true));
// Timer buttons
document.getElementById('btnStartWork').addEventListener('click', startWork);
document.getElementById('btnStopWork').addEventListener('click', stopWork);
// Timer status - manual start time entry
document.getElementById('timerStatus').addEventListener('click', () => {
if (!timerStartTime) {
// Show custom time picker modal
document.getElementById('manualTimePickerModal').classList.remove('hidden');
// Set default time to 09:00
manualStartTimePicker.setDate('09:00', false);
}
});
// Manual time picker - Confirm button
document.getElementById('btnConfirmManualTime').addEventListener('click', async () => {
const timeInput = document.getElementById('manualTimeInput');
const timeStr = timeInput.value; // Format: HH:MM from Flatpickr
if (timeStr && timeStr.match(/^\d{1,2}:\d{2}$/)) {
await handleManualStartTime(timeStr);
document.getElementById('manualTimePickerModal').classList.add('hidden');
} else {
showNotification('Bitte wählen Sie eine gültige Zeit aus', 'error');
}
});
// Manual time picker - Cancel button
document.getElementById('btnCancelManualTime').addEventListener('click', () => {
document.getElementById('manualTimePickerModal').classList.add('hidden');
});
// Close manual time picker when clicking outside
document.getElementById('manualTimePickerModal').addEventListener('click', (e) => {
if (e.target.id === 'manualTimePickerModal') {
document.getElementById('manualTimePickerModal').classList.add('hidden');
}
});
// Bundesland selection
document.getElementById('bundeslandSelect').addEventListener('change', handleBundeslandChange);
// Vacation days input
document.getElementById('vacationDaysInput').addEventListener('change', handleVacationDaysChange);
// Employee name input
document.getElementById('employeeName').addEventListener('change', async (e) => {
await setSetting('employeeName', e.target.value);
showNotification('Mitarbeitername gespeichert', 'success');
});
// Employee ID input
document.getElementById('employeeId').addEventListener('change', async (e) => {
await setSetting('employeeId', e.target.value);
showNotification('Personalnummer gespeichert', 'success');
});
// Database export/import
document.getElementById('btnExportDB').addEventListener('click', exportDatabase);
document.getElementById('btnImportDB').addEventListener('click', () => {
document.getElementById('importDBFile').click();
});
document.getElementById('importDBFile').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
importDatabase(file);
e.target.value = ''; // Reset file input
}
});
// Close modal when clicking outside
document.getElementById('entryModal').addEventListener('click', (e) => {
if (e.target.id === 'entryModal') {
closeModal();
}
});
// Quick time buttons
document.querySelectorAll('.quick-time-btn').forEach(btn => {
btn.addEventListener('click', () => handleQuickTimeButton(parseInt(btn.dataset.hours)));
});
}
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', async () => {
initializeFlatpickr();
initializeEventListeners();
await loadSettings(); // Load saved settings first
checkRunningTimer(); // Check if timer was running
loadMonthlyView(); // Load monthly view by default
});