Files
timetracker/public/app.js

1888 lines
57 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
// ============================================
let currentEditingId = null;
let datePicker = null;
let startTimePicker = null;
let endTimePicker = null;
let filterFromPicker = null;
let filterToPicker = null;
// Timer state
let timerInterval = null;
let timerStartTime = null;
let timerPausedDuration = 0; // Total paused time in seconds
let isPaused = false;
let pauseTimeout = null;
let currentEntryId = null; // ID of today's entry being timed
// Current month display state
let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth(); // 0-11
// Bulk edit state
let bulkEditMode = false;
let selectedEntries = new Set();
// ============================================
// 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 Baden-Württemberg for a given year
*/
function getPublicHolidays(year) {
const holidays = [];
// Fixed holidays
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
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, 10, 1), name: 'Allerheiligen' });
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
// Easter-dependent holidays
const easter = getEasterSunday(year);
// Karfreitag (Good Friday) - 2 days before Easter
const goodFriday = new Date(easter);
goodFriday.setDate(easter.getDate() - 2);
holidays.push({ date: goodFriday, name: 'Karfreitag' });
// Ostermontag (Easter Monday) - 1 day after Easter
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
holidays.push({ date: easterMonday, name: 'Ostermontag' });
// Christi Himmelfahrt (Ascension Day) - 39 days after Easter
const ascension = new Date(easter);
ascension.setDate(easter.getDate() + 39);
holidays.push({ date: ascension, name: 'Christi Himmelfahrt' });
// Pfingstmontag (Whit Monday) - 50 days after Easter
const whitMonday = new Date(easter);
whitMonday.setDate(easter.getDate() + 50);
holidays.push({ date: whitMonday, name: 'Pfingstmontag' });
// Fronleichnam (Corpus Christi) - 60 days after Easter
const corpusChristi = new Date(easter);
corpusChristi.setDate(easter.getDate() + 60);
holidays.push({ date: corpusChristi, name: 'Fronleichnam' });
return holidays;
}
/**
* Check if date is a public holiday in Baden-Württemberg
* Returns the holiday name or null
*/
function getHolidayName(date) {
const year = date.getFullYear();
const holidays = getPublicHolidays(year);
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;
// Hide next month button if it's current month (future)
const today = new Date();
const isCurrentMonth = displayYear === today.getFullYear() && displayMonth === today.getMonth();
const nextBtn = document.getElementById('btnNextMonth');
if (isCurrentMonth) {
nextBtn.style.visibility = 'hidden';
} else {
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() {
const today = new Date();
const nextMonth = displayMonth + 1;
const nextYear = nextMonth > 11 ? displayYear + 1 : displayYear;
const adjustedNextMonth = nextMonth > 11 ? 0 : nextMonth;
// Don't allow going into future
if (nextYear > today.getFullYear() ||
(nextYear === today.getFullYear() && adjustedNextMonth > today.getMonth())) {
return;
}
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();
// Update UI
document.getElementById('btnStartWork').disabled = true;
document.getElementById('btnStopWork').disabled = false;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + todayEntry.startTime;
// Start timer interval
timerInterval = setInterval(updateTimer, 1000);
updateTimer(); // Immediate update
// Schedule automatic pauses
const elapsed = Date.now() - timerStartTime;
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();
timerPausedDuration = 0;
isPaused = false;
// Update UI
document.getElementById('btnStartWork').disabled = true;
document.getElementById('btnStopWork').disabled = false;
document.getElementById('timerStatus').textContent = 'Läuft seit ' + startTime;
// 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
document.getElementById('btnStartWork').disabled = false;
document.getElementById('btnStopWork').disabled = true;
document.getElementById('timerDisplay').textContent = '00:00:00';
document.getElementById('timerStatus').textContent = 'Nicht gestartet';
// Reload monthly view
loadMonthlyView();
}
/**
* Update timer display
*/
function updateTimer() {
if (!timerStartTime) return;
const elapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration;
document.getElementById('timerDisplay').textContent = formatDuration(elapsed);
}
/**
* 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;
document.getElementById('timerStatus').textContent = `Pause (${Math.floor(durationSeconds / 60)} Min)...`;
pauseTimeout = setTimeout(() => {
timerPausedDuration += durationSeconds;
isPaused = false;
document.getElementById('timerStatus').textContent = 'Läuft...';
}, 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;
}
}
/**
* 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' ? '🏠' : '🏢';
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 text-blue-600 hover:text-blue-800 font-medium text-xl" data-id="${entry.id}" title="Bearbeiten">
✏️
</button>
<button class="btn-delete text-red-600 hover:text-red-800 font-medium text-xl" data-id="${entry.id}" title="Löschen">
🗑️
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
// 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();
// Determine last day to show
let lastDay;
if (displayYear === todayYear && displayMonth === todayMonth) {
// Current month: show up to today
lastDay = todayDay;
} else if (displayYear < todayYear || (displayYear === todayYear && displayMonth < todayMonth)) {
// Past month: show all days
lastDay = new Date(displayYear, displayMonth + 1, 0).getDate();
} else {
// Future month: show nothing
lastDay = 0;
}
// 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);
const row = document.createElement('tr');
if (entry) {
// Day has entry
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' ? '🏠' : '🏢';
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 text-blue-600 hover:text-blue-800 font-medium text-xl" data-id="${entry.id}" title="Bearbeiten">
✏️
</button>
<button class="btn-delete text-red-600 hover:text-red-800 font-medium text-xl" data-id="${entry.id}" title="Löschen">
🗑️
</button>
</div>
</td>
`;
} else {
// Day has no entry
const holidayName = getHolidayName(dateObj);
const displayText = holidayName || 'Kein Eintrag';
row.className = weekend ? 'hover:bg-gray-700 bg-gray-700' : 'hover:bg-gray-700 bg-red-900';
// Empty checkbox cell (always present for consistent layout)
const checkboxCell = bulkEditMode ? '<td class="px-2 py-4"></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">
<button class="btn-add-missing text-green-600 hover:text-green-800 font-medium text-xl" data-date="${dateISO}" title="Hinzufügen">
</button>
</td>
`;
}
tbody.appendChild(row);
}
if (lastDay === 0) {
emptyState.classList.remove('hidden');
emptyState.innerHTML = '<p class="text-gray-500 text-lg">Keine Einträge für zukünftige Monate.</p>';
}
// 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)));
});
document.querySelectorAll('.btn-add-missing').forEach(btn => {
btn.addEventListener('click', () => {
const dateISO = btn.dataset.date;
openModalForDate(dateISO);
});
});
// Checkbox event listeners for bulk edit
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', () => {
toggleEntrySelection(parseInt(checkbox.dataset.id));
});
});
}
/**
* 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() {
// 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);
}
/**
* 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;
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
if (!isWeekendOrHoliday(dateObj)) {
workdaysCount++;
if (dateObj <= today) {
workdaysPassed++;
}
}
}
// Calculate target hours (8h per workday passed)
const targetHours = workdaysPassed * 8;
// Calculate actual hours worked
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
// 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;
// Update UI
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
document.getElementById('statWorkdays').textContent = `${entries.length}/${workdaysPassed}`;
// 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';
}
}
/**
* Calculate balance from all previous months (starting from first month with entries)
*/
async function calculatePreviousBalance() {
const currentYear = displayYear;
const currentMonth = displayMonth;
let totalBalance = 0;
let foundFirstMonth = false;
// Go backwards from previous month until we find the first month with entries
let checkYear = currentYear;
let checkMonth = currentMonth - 1;
// Handle year transition
if (checkMonth < 0) {
checkMonth = 11;
checkYear--;
}
// Check up to 24 months back
for (let i = 0; i < 24; i++) {
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);
if (entries.length > 0) {
foundFirstMonth = true;
// Calculate workdays for this month (only up to last day of month or today, whichever is earlier)
const today = new Date();
const monthEnd = new Date(checkYear, checkMonth + 1, 0);
const limitDate = monthEnd < today ? monthEnd : today;
let workdaysPassed = 0;
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
for (let day = 1; day <= monthLastDay; day++) {
const dateObj = new Date(checkYear, checkMonth, day);
if (!isWeekendOrHoliday(dateObj) && dateObj <= limitDate) {
workdaysPassed++;
}
}
const targetHours = workdaysPassed * 8;
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
const monthBalance = actualHours - targetHours;
totalBalance += monthBalance;
} else if (foundFirstMonth) {
// If we found entries in a later month but not in this one, stop
// (only count months after the first month with entries)
break;
}
// Move to previous month
checkMonth--;
if (checkMonth < 0) {
checkMonth = 11;
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-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold 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-colors font-semibold flex items-center justify-center gap-2';
} else {
officeBtn.className = 'flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold flex items-center justify-center gap-2';
homeBtn.className = 'flex-1 px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-semibold flex items-center justify-center gap-2';
}
}
/**
* 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 loadMonthlyView();
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 = 'px-4 py-3 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors text-xl';
toggleBtn.title = 'Mehrfachauswahl deaktivieren';
} else {
bulkEditBar.classList.add('hidden');
checkboxHeader.classList.add('hidden');
toggleBtn.className = 'px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-xl';
toggleBtn.title = 'Mehrfachauswahl aktivieren';
}
// Reload view to show/hide checkboxes
loadMonthlyView();
}
/**
* Toggle entry selection
*/
function toggleEntrySelection(id) {
if (selectedEntries.has(id)) {
selectedEntries.delete(id);
} else {
selectedEntries.add(id);
}
updateSelectedCount();
updateCheckboxes();
}
/**
* Select all visible entries
*/
function selectAllEntries() {
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
const id = parseInt(checkbox.dataset.id);
selectedEntries.add(id);
checkbox.checked = true;
});
updateSelectedCount();
}
/**
* Deselect all entries
*/
function deselectAllEntries() {
selectedEntries.clear();
document.querySelectorAll('.entry-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');
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() {
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
const id = parseInt(checkbox.dataset.id);
checkbox.checked = selectedEntries.has(id);
});
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();
await loadMonthlyView();
showNotification(`${updated} Eintrag/Einträge aktualisiert`, 'success');
}
/**
* 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();
await loadMonthlyView();
showNotification(`${deleted} Eintrag/Einträge gelöscht`, '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;
loadEntries(fromDate, toDate);
}
/**
* Handle clear filter button click
*/
function handleClearFilter() {
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
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);Abweichung (h)\n';
const csvRows = entries.map(entry => {
const deviation = entry.netHours - 8.0;
const deviationStr = (deviation >= 0 ? '+' : '') + deviation.toFixed(2);
return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${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');
}
}
/**
* 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: 1 (or 15 for larger steps)
// - Mobile browsers will show native time picker automatically
const timeConfig = {
enableTime: true,
noCalendar: true,
dateFormat: 'H:i',
time_24hr: true,
minuteIncrement: 1,
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
};
startTimePicker = flatpickr('#modalStartTime', timeConfig);
endTimePicker = flatpickr('#modalEndTime', timeConfig);
}
// ============================================
// EVENT LISTENERS
// ============================================
function initializeEventListeners() {
// Add entry button
document.getElementById('btnAddEntry').addEventListener('click', () => openModal());
// Auto-fill month button
document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth);
// 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('btnBulkDelete').addEventListener('click', bulkDeleteEntries);
// 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);
// 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', () => {
initializeFlatpickr();
initializeEventListeners();
checkRunningTimer(); // Check if timer was running
loadMonthlyView(); // Load monthly view by default
});