1888 lines
57 KiB
JavaScript
1888 lines
57 KiB
JavaScript
// ============================================
|
||
// 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 (only in bulk edit mode)
|
||
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>
|
||
` : '';
|
||
|
||
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 (only in bulk edit mode)
|
||
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>
|
||
` : '';
|
||
|
||
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 in bulk edit mode
|
||
const checkboxCell = bulkEditMode ? '<td class="px-2 py-4"></td>' : '';
|
||
const colspan = bulkEditMode ? '6' : '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
|
||
});
|