3961 lines
132 KiB
JavaScript
3961 lines
132 KiB
JavaScript
// ============================================
|
|
// STATE & VARIABLES (additional to state.js)
|
|
// ============================================
|
|
let manualStartTimePicker = null;
|
|
|
|
// Additional timer state (beyond state.js)
|
|
let pauseStartElapsed = 0; // Elapsed time when pause started (to freeze display)
|
|
let pauseEndTime = 0; // Timestamp when pause will end (for countdown)
|
|
let timerStartTimeString = ''; // Start time as string (HH:MM) for display
|
|
|
|
// Current view state
|
|
let currentView = 'monthly'; // 'monthly' or 'filter'
|
|
let currentFilterFrom = null;
|
|
let currentFilterTo = null;
|
|
|
|
// Settings state
|
|
let currentBundesland = 'BW'; // Default: Baden-Württemberg
|
|
let totalVacationDays = 30; // Default vacation days per year
|
|
|
|
// ============================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================
|
|
|
|
/**
|
|
* Get day of week abbreviation in German
|
|
*/
|
|
function getDayOfWeek(date) {
|
|
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
|
return days[date.getDay()];
|
|
}
|
|
|
|
/**
|
|
* Get month name in German
|
|
*/
|
|
function getMonthName(month) {
|
|
const months = [
|
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
|
];
|
|
return months[month];
|
|
}
|
|
|
|
/**
|
|
* Update month display
|
|
*/
|
|
function updateMonthDisplay() {
|
|
const monthName = getMonthName(displayMonth);
|
|
const displayText = `${monthName} ${displayYear}`;
|
|
document.getElementById('currentMonthDisplay').textContent = displayText;
|
|
|
|
// Always show next month button (allow navigation to future)
|
|
const nextBtn = document.getElementById('btnNextMonth');
|
|
nextBtn.style.visibility = 'visible';
|
|
}
|
|
|
|
/**
|
|
* Navigate to previous month
|
|
*/
|
|
function handlePrevMonth() {
|
|
if (displayMonth === 0) {
|
|
displayMonth = 11;
|
|
displayYear--;
|
|
} else {
|
|
displayMonth--;
|
|
}
|
|
loadMonthlyView();
|
|
}
|
|
|
|
/**
|
|
* Navigate to next month
|
|
*/
|
|
function handleNextMonth() {
|
|
// Allow navigation to future months
|
|
if (displayMonth === 11) {
|
|
displayMonth = 0;
|
|
displayYear++;
|
|
} else {
|
|
displayMonth++;
|
|
}
|
|
loadMonthlyView();
|
|
}
|
|
|
|
// ============================================
|
|
// TIMER FUNCTIONS
|
|
// ============================================
|
|
|
|
/**
|
|
* Update timer status text and show/hide metrics
|
|
*/
|
|
function setTimerStatus(text, showMetrics = false) {
|
|
const statusTextEl = document.getElementById('timerStatusText');
|
|
const metricsEl = document.getElementById('timerMetrics');
|
|
const manualTimeLink = document.getElementById('manualTimeLink');
|
|
|
|
if (statusTextEl) {
|
|
statusTextEl.textContent = text;
|
|
}
|
|
|
|
if (metricsEl) {
|
|
if (showMetrics) {
|
|
metricsEl.classList.remove('hidden');
|
|
if (manualTimeLink) manualTimeLink.classList.add('hidden');
|
|
} else {
|
|
metricsEl.classList.add('hidden');
|
|
if (manualTimeLink) manualTimeLink.classList.remove('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if timer is running (on page load)
|
|
*/
|
|
async function checkRunningTimer() {
|
|
const today = getTodayISO();
|
|
const entries = await fetchEntries(today, today);
|
|
|
|
if (entries.length > 0) {
|
|
const todayEntry = entries[0];
|
|
// Check if entry has start but same start and end (running timer)
|
|
if (todayEntry.startTime === todayEntry.endTime) {
|
|
// Timer is running
|
|
currentEntryId = todayEntry.id;
|
|
|
|
// Calculate start time from DB
|
|
const [hours, minutes] = todayEntry.startTime.split(':').map(Number);
|
|
const startDate = new Date();
|
|
startDate.setHours(hours, minutes, 0, 0);
|
|
timerStartTime = startDate.getTime();
|
|
timerStartTimeString = todayEntry.startTime;
|
|
|
|
// Update UI
|
|
const startBtn = document.getElementById('btnStartWork');
|
|
const stopBtn = document.getElementById('btnStopWork');
|
|
startBtn.disabled = true;
|
|
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
startBtn.classList.remove('hover:bg-green-700');
|
|
stopBtn.disabled = false;
|
|
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
stopBtn.classList.add('hover:bg-red-700');
|
|
setTimerStatus('Läuft seit ' + todayEntry.startTime, true);
|
|
|
|
// Calculate elapsed time and check for active pauses
|
|
const elapsed = Date.now() - timerStartTime;
|
|
const elapsedSeconds = Math.floor(elapsed / 1000);
|
|
|
|
// Check if we're in a pause
|
|
const sixHoursSeconds = 6 * 60 * 60;
|
|
const nineHoursSeconds = 9 * 60 * 60;
|
|
const thirtyMinutes = 30 * 60;
|
|
const fifteenMinutes = 15 * 60;
|
|
|
|
// Check if in 6-hour pause (6h to 6h30m real time)
|
|
if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) {
|
|
isPaused = true;
|
|
pauseStartElapsed = sixHoursSeconds;
|
|
timerPausedDuration = 0;
|
|
const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds;
|
|
pauseEndTime = Date.now() + (remainingPause * 1000);
|
|
setTimerStatus(`Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true);
|
|
|
|
// Schedule end of pause
|
|
pauseTimeout = setTimeout(() => {
|
|
timerPausedDuration = thirtyMinutes;
|
|
isPaused = false;
|
|
pauseStartElapsed = 0;
|
|
pauseEndTime = 0;
|
|
setTimerStatus('Läuft seit ' + todayEntry.startTime, true);
|
|
}, remainingPause * 1000);
|
|
}
|
|
// Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time)
|
|
else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
|
|
isPaused = true;
|
|
pauseStartElapsed = nineHoursSeconds; // Work time when pause starts
|
|
timerPausedDuration = thirtyMinutes;
|
|
const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds;
|
|
pauseEndTime = Date.now() + (remainingPause * 1000);
|
|
setTimerStatus(`Läuft seit ${todayEntry.startTime} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true);
|
|
|
|
// Schedule end of pause
|
|
pauseTimeout = setTimeout(() => {
|
|
timerPausedDuration = thirtyMinutes + fifteenMinutes;
|
|
isPaused = false;
|
|
pauseStartElapsed = 0;
|
|
pauseEndTime = 0;
|
|
setTimerStatus('Läuft seit ' + todayEntry.startTime, true);
|
|
}, remainingPause * 1000);
|
|
}
|
|
// Not in pause, but may have completed pauses
|
|
else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) {
|
|
// After first pause, before second pause
|
|
timerPausedDuration = thirtyMinutes;
|
|
} else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
|
|
// After both pauses
|
|
timerPausedDuration = thirtyMinutes + fifteenMinutes;
|
|
}
|
|
|
|
// Start timer interval
|
|
timerInterval = setInterval(updateTimer, 1000);
|
|
updateTimer(); // Immediate update
|
|
|
|
// Schedule automatic pauses
|
|
schedulePausesWithOffset(elapsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start work timer
|
|
*/
|
|
async function startWork() {
|
|
const now = new Date();
|
|
const roundedStart = roundDownTo15Min(new Date(now));
|
|
const startTime = formatTime(roundedStart);
|
|
const today = getTodayISO();
|
|
|
|
// Create entry with same start and end time (indicates running)
|
|
const entry = await createEntry(formatDateDisplay(today), startTime, startTime, null);
|
|
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
currentEntryId = entry.id;
|
|
timerStartTime = roundedStart.getTime();
|
|
timerStartTimeString = startTime;
|
|
timerPausedDuration = 0;
|
|
isPaused = false;
|
|
|
|
// Update UI
|
|
const startBtn = document.getElementById('btnStartWork');
|
|
const stopBtn = document.getElementById('btnStopWork');
|
|
startBtn.disabled = true;
|
|
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
startBtn.classList.remove('hover:bg-green-700');
|
|
stopBtn.disabled = false;
|
|
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
stopBtn.classList.add('hover:bg-red-700');
|
|
setTimerStatus('Läuft seit ' + startTime, true);
|
|
|
|
// Start timer interval
|
|
timerInterval = setInterval(updateTimer, 1000);
|
|
|
|
// Schedule automatic pauses
|
|
schedulePausesWithOffset(0);
|
|
|
|
// Reload view to show entry
|
|
loadMonthlyView();
|
|
}
|
|
|
|
/**
|
|
* Stop work timer
|
|
*/
|
|
async function stopWork() {
|
|
if (!currentEntryId) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(timerInterval);
|
|
if (pauseTimeout) clearTimeout(pauseTimeout);
|
|
|
|
const now = new Date();
|
|
const roundedEnd = roundUpTo15Min(new Date(now));
|
|
const endTime = formatTime(roundedEnd);
|
|
const today = getTodayISO();
|
|
|
|
// Fetch the entry to get start time
|
|
const entries = await fetchEntries(today, today);
|
|
const entry = entries.find(e => e.id === currentEntryId);
|
|
|
|
if (entry) {
|
|
// Update entry with end time
|
|
await updateEntry(currentEntryId, formatDateDisplay(today), entry.startTime, endTime, null);
|
|
}
|
|
|
|
// Reset timer state
|
|
currentEntryId = null;
|
|
timerStartTime = null;
|
|
timerPausedDuration = 0;
|
|
isPaused = false;
|
|
|
|
// Update UI
|
|
const startBtn = document.getElementById('btnStartWork');
|
|
const stopBtn = document.getElementById('btnStopWork');
|
|
startBtn.disabled = false;
|
|
startBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
startBtn.classList.add('hover:bg-green-700');
|
|
stopBtn.disabled = true;
|
|
stopBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
stopBtn.classList.remove('hover:bg-red-700');
|
|
document.getElementById('timerDisplay').textContent = '00:00:00';
|
|
setTimerStatus('Nicht gestartet', false);
|
|
|
|
// Reload monthly view
|
|
loadMonthlyView();
|
|
}
|
|
|
|
/**
|
|
* Update timer display
|
|
*/
|
|
function updateTimer() {
|
|
if (!timerStartTime) return;
|
|
|
|
const now = Date.now();
|
|
let elapsed;
|
|
|
|
// If currently paused, show the frozen time (time when pause started)
|
|
if (isPaused) {
|
|
elapsed = pauseStartElapsed;
|
|
|
|
// Update pause countdown live
|
|
const remainingSeconds = Math.max(0, Math.ceil((pauseEndTime - now) / 1000));
|
|
const remainingMinutes = Math.ceil(remainingSeconds / 60);
|
|
setTimerStatus(`Läuft seit ${timerStartTimeString} - Pause (${remainingMinutes} Min)`, true);
|
|
} else {
|
|
elapsed = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration;
|
|
|
|
// Check if 10h net time reached - auto stop timer
|
|
const netHours = elapsed / 3600;
|
|
if (netHours >= 10) {
|
|
showNotification('🛑 Maximale Arbeitszeit (10h netto) erreicht. Timer wird automatisch gestoppt.', 'warning');
|
|
stopWork();
|
|
return;
|
|
}
|
|
}
|
|
|
|
document.getElementById('timerDisplay').textContent = formatDuration(elapsed);
|
|
|
|
// Calculate and display additional timer metrics
|
|
updateTimerMetrics(elapsed);
|
|
|
|
// Update live net hours in the table for current day
|
|
const netHoursCell = document.getElementById('current-day-net-hours');
|
|
if (netHoursCell) {
|
|
const netHours = elapsed / 3600; // Convert seconds to hours
|
|
netHoursCell.textContent = netHours.toFixed(2);
|
|
}
|
|
|
|
// Update live balance in the table for current day
|
|
const balanceCell = document.getElementById('current-day-balance');
|
|
if (balanceCell) {
|
|
const netHours = elapsed / 3600; // Convert seconds to hours
|
|
const deviation = netHours - targetHours;
|
|
const baseBalance = parseFloat(balanceCell.dataset.baseBalance || 0);
|
|
const currentBalance = baseBalance + deviation;
|
|
|
|
const balanceColor = currentBalance >= 0 ? 'text-green-400' : 'text-red-400';
|
|
const balanceSign = currentBalance >= 0 ? '+' : '';
|
|
|
|
balanceCell.className = `px-4 py-3 whitespace-nowrap text-sm font-semibold ${balanceColor}`;
|
|
balanceCell.textContent = `${balanceSign}${currentBalance.toFixed(2)}h`;
|
|
}
|
|
|
|
// Update live pause minutes in the table for current day
|
|
const pauseCell = document.getElementById('current-day-pause');
|
|
if (pauseCell) {
|
|
// Calculate total pause time in minutes
|
|
let totalPauseMinutes = Math.floor(timerPausedDuration / 60);
|
|
|
|
// If currently in a pause, add the elapsed pause time
|
|
if (isPaused) {
|
|
const pauseDuration = Math.floor((now - (pauseEndTime - (isPaused ? (pauseEndTime - now) : 0))) / 1000);
|
|
// Determine which pause we're in and add its duration to the display
|
|
const sixHoursSeconds = 6 * 60 * 60;
|
|
const nineHoursSeconds = 9 * 60 * 60;
|
|
const elapsedTotal = Math.floor((now - timerStartTime) / 1000);
|
|
|
|
if (elapsedTotal >= sixHoursSeconds && elapsedTotal < sixHoursSeconds + 30 * 60) {
|
|
// In 6-hour pause (30 minutes)
|
|
totalPauseMinutes = 30;
|
|
} else if (elapsedTotal >= nineHoursSeconds + 30 * 60 && elapsedTotal < nineHoursSeconds + 30 * 60 + 15 * 60) {
|
|
// In 9-hour pause (30 + 15 minutes)
|
|
totalPauseMinutes = 30 + 15;
|
|
}
|
|
}
|
|
|
|
pauseCell.textContent = totalPauseMinutes;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate and update additional timer metrics
|
|
*/
|
|
function updateTimerMetrics(netElapsedSeconds) {
|
|
const targetReachedTimeSpan = document.getElementById('targetReachedTime');
|
|
const timeUntilTargetSpan = document.getElementById('timeUntilTarget');
|
|
|
|
if (!timerStartTime) {
|
|
return;
|
|
}
|
|
|
|
// Target hours from user selection (default 8)
|
|
const targetSeconds = targetHours * 60 * 60;
|
|
|
|
// Calculate total pause time: 30 min after 6h + 15 min after 9h
|
|
const pauseDuration30Min = 30 * 60; // 30 minutes in seconds
|
|
const pauseDuration15Min = 15 * 60; // 15 minutes in seconds
|
|
|
|
// Time needed including pauses
|
|
// After 6h work -> 30 min pause
|
|
// After 9h work -> 15 min pause
|
|
// Total gross time = target work hours + 30min pause + 15min pause
|
|
const totalGrossTimeNeeded = targetSeconds + pauseDuration30Min + pauseDuration15Min;
|
|
|
|
// Calculate when target will be reached (clock time)
|
|
const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000);
|
|
const targetHoursTime = String(targetReachedTimestamp.getHours()).padStart(2, '0');
|
|
const targetMinutesDisplay = String(targetReachedTimestamp.getMinutes()).padStart(2, '0');
|
|
targetReachedTimeSpan.textContent = `${targetHoursTime}:${targetMinutesDisplay}`;
|
|
|
|
// Calculate countdown to target (remaining net work time)
|
|
const remainingNetSeconds = Math.max(0, targetSeconds - netElapsedSeconds);
|
|
|
|
if (remainingNetSeconds === 0) {
|
|
timeUntilTargetSpan.textContent = '00:00:00';
|
|
timeUntilTargetSpan.classList.add('text-green-400');
|
|
} else {
|
|
// Format as HH:MM:SS
|
|
const hours = Math.floor(remainingNetSeconds / 3600);
|
|
const minutes = Math.floor((remainingNetSeconds % 3600) / 60);
|
|
const seconds = remainingNetSeconds % 60;
|
|
|
|
timeUntilTargetSpan.textContent =
|
|
`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
timeUntilTargetSpan.classList.remove('text-green-400');
|
|
}
|
|
|
|
// Reinitialize Lucide icons for the new metrics
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule automatic pauses at 6h and 9h with offset for existing elapsed time
|
|
*/
|
|
function schedulePausesWithOffset(elapsedMs) {
|
|
const sixHoursMs = 6 * 60 * 60 * 1000;
|
|
const nineHoursMs = 9 * 60 * 60 * 1000;
|
|
|
|
// Pause at 6 hours for 30 minutes
|
|
if (elapsedMs < sixHoursMs) {
|
|
setTimeout(() => {
|
|
if (timerStartTime && !isPaused) {
|
|
pauseTimer(30 * 60); // 30 minutes
|
|
showNotification('⏸️ Automatische Pause: 30 Minuten (nach 6 Stunden)', 'info');
|
|
}
|
|
}, sixHoursMs - elapsedMs);
|
|
}
|
|
|
|
// Additional pause at 9 hours for 15 minutes
|
|
if (elapsedMs < nineHoursMs) {
|
|
setTimeout(() => {
|
|
if (timerStartTime && !isPaused) {
|
|
pauseTimer(15 * 60); // 15 minutes
|
|
showNotification('⏸️ Automatische Pause: 15 Minuten (nach 9 Stunden)', 'info');
|
|
}
|
|
}, nineHoursMs - elapsedMs);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pause timer for specified duration
|
|
*/
|
|
function pauseTimer(durationSeconds) {
|
|
isPaused = true;
|
|
pauseEndTime = Date.now() + (durationSeconds * 1000);
|
|
|
|
// Store the elapsed time when pause starts (this is what we want to freeze at)
|
|
pauseStartElapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration;
|
|
|
|
pauseTimeout = setTimeout(() => {
|
|
timerPausedDuration += durationSeconds;
|
|
isPaused = false;
|
|
pauseEndTime = 0;
|
|
setTimerStatus('Läuft seit ' + timerStartTimeString, true);
|
|
}, durationSeconds * 1000);
|
|
}
|
|
|
|
// ============================================
|
|
// SETTINGS API
|
|
// ============================================
|
|
|
|
/**
|
|
* Get a setting by key
|
|
*/
|
|
async function getSetting(key) {
|
|
try {
|
|
const response = await fetch(`/api/settings/${key}`);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
return null; // Setting doesn't exist yet
|
|
}
|
|
throw new Error('Failed to get setting');
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.value;
|
|
} catch (error) {
|
|
console.error('Error getting setting:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set a setting
|
|
*/
|
|
async function setSetting(key, value) {
|
|
try {
|
|
const response = await fetch('/api/settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ key, value })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to set setting');
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error setting setting:', error);
|
|
showNotification('Fehler beim Speichern der Einstellung', 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export entries as CSV
|
|
*/
|
|
async function exportEntries(fromDate = null, toDate = null) {
|
|
try {
|
|
let url = '/api/export';
|
|
const params = new URLSearchParams();
|
|
|
|
if (fromDate) params.append('from', fromDate);
|
|
if (toDate) params.append('to', toDate);
|
|
|
|
if (params.toString()) {
|
|
url += '?' + params.toString();
|
|
}
|
|
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to export entries');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const downloadUrl = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = downloadUrl;
|
|
a.download = 'zeiterfassung.csv';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(downloadUrl);
|
|
|
|
showNotification('Export erfolgreich', 'success');
|
|
} catch (error) {
|
|
console.error('Error exporting entries:', error);
|
|
showNotification('Fehler beim Exportieren', 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// UI FUNCTIONS
|
|
// ============================================
|
|
|
|
/**
|
|
* Render entries in the table
|
|
*/
|
|
function renderEntries(entries) {
|
|
const tbody = document.getElementById('entriesTableBody');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
if (entries.length === 0) {
|
|
emptyState.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
emptyState.classList.add('hidden');
|
|
|
|
// Running balance accumulator
|
|
let runningBalance = 0;
|
|
|
|
entries.forEach(entry => {
|
|
const row = document.createElement('tr');
|
|
const dateObj = new Date(entry.date + 'T00:00:00');
|
|
const dayOfWeek = getDayOfWeek(dateObj);
|
|
const weekend = isWeekendOrHoliday(dateObj);
|
|
const location = entry.location || 'office';
|
|
|
|
// Row color based on location
|
|
let rowClass = 'hover:bg-gray-700';
|
|
if (location === 'home') {
|
|
rowClass = 'hover:bg-green-900 bg-green-950';
|
|
} else if (weekend) {
|
|
rowClass = 'hover:bg-gray-700 bg-gray-700';
|
|
}
|
|
|
|
row.className = rowClass;
|
|
row.dataset.id = entry.id;
|
|
|
|
// Location icon and text
|
|
const locationIcon = location === 'home'
|
|
? '<i data-lucide="home" class="w-4 h-4 inline"></i>'
|
|
: '<i data-lucide="building-2" class="w-4 h-4 inline"></i>';
|
|
const locationText = location === 'home' ? 'Home' : 'Büro';
|
|
|
|
// Checkbox column (always present for consistent layout)
|
|
const checkboxCell = bulkEditMode ? `
|
|
<td class="px-2 py-4 whitespace-nowrap text-center">
|
|
<input type="checkbox" class="entry-checkbox w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
|
|
data-id="${entry.id}" ${selectedEntries.has(entry.id) ? 'checked' : ''}>
|
|
</td>
|
|
` : '<td class="hidden"></td>';
|
|
|
|
// Calculate balance: for work and flextime entries (excluding vacation)
|
|
const entryType = entry.entryType || 'work';
|
|
let balanceCell = '';
|
|
|
|
if (entryType !== 'vacation') {
|
|
// For all workdays (including flextime): Actual - Target (8h)
|
|
// Flextime has netHours = 0, so deviation will be -8h
|
|
const deviation = entry.netHours - 8.0;
|
|
runningBalance += deviation;
|
|
const balanceColor = runningBalance >= 0 ? 'text-green-400' : 'text-red-400';
|
|
const balanceSign = runningBalance >= 0 ? '+' : '';
|
|
balanceCell = `<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold ${balanceColor}">${balanceSign}${runningBalance.toFixed(2)}h</td>`;
|
|
} else {
|
|
balanceCell = '<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">-</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>
|
|
${balanceCell}
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-center text-gray-100">
|
|
<span class="text-lg">${locationIcon}</span> ${locationText}
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-center">
|
|
<div class="flex gap-2 justify-center">
|
|
<button class="btn-edit inline-flex items-center justify-center w-8 h-8 text-blue-500 hover:text-blue-600 hover:bg-blue-500/10 rounded transition-all" data-id="${entry.id}" title="Bearbeiten">
|
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
|
</button>
|
|
<button class="btn-delete inline-flex items-center justify-center w-8 h-8 text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded transition-all" data-id="${entry.id}" title="Löschen">
|
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
// Reinitialize Lucide icons
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Add event listeners
|
|
attachInlineEditListeners();
|
|
|
|
document.querySelectorAll('.btn-edit').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = parseInt(btn.dataset.id);
|
|
const entries = await fetchEntries();
|
|
const entry = entries.find(e => e.id === id);
|
|
if (entry) {
|
|
openModal(entry);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-delete').forEach(btn => {
|
|
btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id)));
|
|
});
|
|
|
|
// Checkbox event listeners for bulk edit
|
|
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', () => {
|
|
toggleEntrySelection(parseInt(checkbox.dataset.id));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render monthly view with all days
|
|
*/
|
|
function renderMonthlyView(entries) {
|
|
const tbody = document.getElementById('entriesTableBody');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
tbody.innerHTML = '';
|
|
emptyState.classList.add('hidden');
|
|
|
|
const today = new Date();
|
|
const todayYear = today.getFullYear();
|
|
const todayMonth = today.getMonth();
|
|
const todayDay = today.getDate();
|
|
|
|
// Show all days of the month
|
|
const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate();
|
|
|
|
// Create a map of entries by date
|
|
const entriesMap = {};
|
|
entries.forEach(entry => {
|
|
entriesMap[entry.date] = entry;
|
|
});
|
|
|
|
// Running balance accumulator
|
|
let runningBalance = 0;
|
|
|
|
// Render all days from 1st to lastDay
|
|
for (let day = 1; day <= lastDay; day++) {
|
|
const dateObj = new Date(displayYear, displayMonth, day);
|
|
const dateISO = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
const entry = entriesMap[dateISO];
|
|
const dayOfWeek = getDayOfWeek(dateObj);
|
|
const weekend = isWeekendOrHoliday(dateObj);
|
|
|
|
// Check if this is today
|
|
const isToday = dateISO === getTodayISO();
|
|
|
|
const row = document.createElement('tr');
|
|
|
|
if (entry) {
|
|
// Day has entry
|
|
const location = entry.location || 'office';
|
|
const entryType = entry.entryType || 'work';
|
|
|
|
// Row color based on entry type and location
|
|
let rowClass = 'hover:bg-slate-600/40 transition-colors';
|
|
if (entryType === 'vacation') {
|
|
rowClass = 'hover:bg-yellow-800/50 bg-yellow-900/30 transition-colors';
|
|
} else if (entryType === 'flextime') {
|
|
rowClass = 'hover:bg-cyan-800/50 bg-cyan-900/30 transition-colors';
|
|
} else if (location === 'home') {
|
|
rowClass = 'hover:bg-green-800/50 bg-green-900/30 transition-colors';
|
|
} else if (weekend) {
|
|
rowClass = 'hover:bg-slate-600/50 bg-slate-700/30 transition-colors';
|
|
}
|
|
|
|
|
|
row.className = rowClass;
|
|
row.dataset.id = entry.id;
|
|
|
|
// Add inline border style for today
|
|
if (isToday) {
|
|
row.style.borderLeft = '4px solid #3b82f6'; // blue-500
|
|
row.id = 'current-day-row'; // Add ID for live updates
|
|
}
|
|
|
|
// Icon and text based on entry type
|
|
let displayIcon, displayText, displayTimes;
|
|
if (entryType === 'vacation') {
|
|
displayIcon = '<i data-lucide="plane" class="w-4 h-4 inline"></i>';
|
|
displayText = 'Urlaub';
|
|
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
|
|
<span class="italic">Urlaub</span>
|
|
</td>`;
|
|
} else if (entryType === 'flextime') {
|
|
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
|
|
displayText = 'Gleitzeit';
|
|
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
|
|
<span class="italic">Gleittag (8h)</span>
|
|
</td>`;
|
|
} else {
|
|
displayIcon = location === 'home'
|
|
? '<i data-lucide="home" class="w-4 h-4 inline"></i>'
|
|
: '<i data-lucide="building-2" class="w-4 h-4 inline"></i>';
|
|
displayText = location === 'home' ? 'Home' : 'Büro';
|
|
|
|
// Check if timer is running (start == end time)
|
|
const isTimerRunning = entry.startTime === entry.endTime;
|
|
const endTimeDisplay = isTimerRunning
|
|
? '<i data-lucide="clock" class="w-4 h-4 inline timer-running-icon"></i>'
|
|
: entry.endTime;
|
|
|
|
displayTimes = `
|
|
<td class="px-4 py-3 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-4 py-3 whitespace-nowrap text-sm text-gray-100 ${isTimerRunning ? '' : 'editable-cell'}"
|
|
${isTimerRunning ? '' : `data-field="endTime" data-id="${entry.id}" data-value="${entry.endTime}"`}>
|
|
${endTimeDisplay}
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-100 ${isTimerRunning ? '' : 'editable-cell'}"
|
|
${isTimerRunning ? `id="current-day-pause"` : `data-field="pauseMinutes" data-id="${entry.id}" data-value="${entry.pauseMinutes}"`}>
|
|
${entry.pauseMinutes}
|
|
</td>`;
|
|
}
|
|
|
|
// Checkbox column (always present for consistent layout)
|
|
const checkboxCell = bulkEditMode ? `
|
|
<td class="px-2 py-3 whitespace-nowrap text-center">
|
|
<input type="checkbox" class="entry-checkbox w-4 h-4 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>';
|
|
|
|
// Calculate balance: only for past days (excluding vacation)
|
|
const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay);
|
|
let balanceCell = '';
|
|
|
|
if (isPastDay && entryType !== 'vacation') {
|
|
// For all workdays (including flextime): Actual - Target (8h default)
|
|
// Flextime has netHours = 0, so deviation will be -8h
|
|
|
|
// For current day: store balance before today, then use custom target hours in live update
|
|
const balanceBeforeToday = runningBalance;
|
|
const deviation = entry.netHours - 8.0;
|
|
runningBalance += deviation;
|
|
|
|
const balanceColor = runningBalance >= 0 ? 'text-green-400' : 'text-red-400';
|
|
const balanceSign = runningBalance >= 0 ? '+' : '';
|
|
|
|
// Add ID for current day to enable live updates
|
|
// Store balance before today, so live update can add current day's deviation with custom target
|
|
const balanceId = isToday && entryType === 'work' ? 'id="current-day-balance"' : '';
|
|
const balanceDataAttr = isToday && entryType === 'work' ? `data-base-balance="${balanceBeforeToday}"` : '';
|
|
|
|
balanceCell = `<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold ${balanceColor}" ${balanceId} ${balanceDataAttr}>${balanceSign}${runningBalance.toFixed(2)}h</td>`;
|
|
} else {
|
|
balanceCell = '<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">-</td>';
|
|
}
|
|
|
|
row.innerHTML = checkboxCell + `
|
|
<td class="px-2 py-3 whitespace-nowrap text-sm font-medium ${weekend ? 'text-blue-400' : 'text-gray-100'}">${dayOfWeek}</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-100">${formatDateDisplay(entry.date)}</td>
|
|
${displayTimes}
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-semibold text-gray-100" id="${isToday ? 'current-day-net-hours' : ''}">${entry.netHours.toFixed(2)}</td>
|
|
${balanceCell}
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-100">
|
|
<span class="text-lg">${displayIcon}</span> ${displayText}
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-center">
|
|
<div class="flex gap-2 justify-center">
|
|
${entryType === 'work' ? `
|
|
<button class="btn-edit inline-flex items-center justify-center w-8 h-8 text-blue-400/80 hover:text-blue-400 hover:bg-blue-500/10 rounded transition-all" data-id="${entry.id}" title="Bearbeiten">
|
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
|
</button>
|
|
` : ''}
|
|
<button class="btn-delete inline-flex items-center justify-center w-8 h-8 text-red-400/80 hover:text-red-400 hover:bg-red-500/10 rounded transition-all" data-id="${entry.id}" title="Löschen">
|
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
} else {
|
|
// Day has no entry - show add options
|
|
const holidayName = getHolidayName(dateObj);
|
|
const displayText = holidayName || 'Kein Eintrag';
|
|
|
|
// Don't mark future days as red, only past workdays without entries
|
|
const isFutureDay = dateObj > today;
|
|
let emptyRowClass = weekend ? 'hover:bg-slate-600/50 bg-slate-700/30 transition-colors' :
|
|
isFutureDay ? 'hover:bg-slate-600/40 transition-colors' : 'hover:bg-slate-600/40 bg-red-900/30 transition-colors';
|
|
|
|
|
|
row.className = emptyRowClass;
|
|
row.dataset.date = dateISO; // Store date for bulk operations
|
|
|
|
// Add inline border style for today
|
|
if (isToday) {
|
|
row.style.borderLeft = '4px solid #3b82f6'; // blue-500
|
|
}
|
|
|
|
// Checkbox cell for empty days (in bulk edit mode)
|
|
const checkboxCell = bulkEditMode ? `
|
|
<td class="px-2 py-3 whitespace-nowrap text-center">
|
|
<input type="checkbox" class="empty-day-checkbox w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
|
|
data-date="${dateISO}" ${selectedEntries.has(dateISO) ? 'checked' : ''}>
|
|
</td>
|
|
` : '<td class="hidden"></td>';
|
|
const colspan = bulkEditMode ? '6' : '6';
|
|
|
|
row.innerHTML = checkboxCell + `
|
|
<td class="px-2 py-3 whitespace-nowrap text-sm font-medium ${weekend ? 'text-blue-400' : 'text-gray-100'}">${dayOfWeek}</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-100">${formatDateDisplay(dateISO)}</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm ${holidayName ? 'text-blue-400 font-semibold' : 'text-gray-500'}" colspan="${colspan}">
|
|
<span class="italic">${displayText}</span>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-center">
|
|
<div class="flex gap-1 justify-center">
|
|
${!holidayName ? `
|
|
<button class="btn-add-work inline-flex items-center justify-center w-7 h-7 text-indigo-400/80 hover:text-indigo-400 hover:bg-indigo-500/10 rounded transition-all" data-date="${dateISO}" title="Arbeit eintragen">
|
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
|
</button>
|
|
` : ''}
|
|
${!weekend ? `
|
|
<button class="btn-add-vacation inline-flex items-center justify-center w-7 h-7 text-amber-400/80 hover:text-amber-400 hover:bg-amber-500/10 rounded transition-all" data-date="${dateISO}" title="Urlaub eintragen">
|
|
<i data-lucide="plane" class="w-4 h-4"></i>
|
|
</button>
|
|
<button class="btn-add-flextime inline-flex items-center justify-center w-7 h-7 text-cyan-400/80 hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-all" data-date="${dateISO}" title="Gleitzeit eintragen">
|
|
<i data-lucide="clock" class="w-4 h-4"></i>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</td>
|
|
`;
|
|
}
|
|
|
|
tbody.appendChild(row);
|
|
}
|
|
|
|
// Reinitialize Lucide icons
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Add event listeners
|
|
attachInlineEditListeners();
|
|
|
|
document.querySelectorAll('.btn-edit').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = parseInt(btn.dataset.id);
|
|
const entries = await fetchEntries();
|
|
const entry = entries.find(e => e.id === id);
|
|
if (entry) {
|
|
openModal(entry);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.btn-delete').forEach(btn => {
|
|
btn.addEventListener('click', () => handleDelete(parseInt(btn.dataset.id)));
|
|
});
|
|
|
|
// Add work entry for a specific date
|
|
document.querySelectorAll('.btn-add-work').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const dateISO = btn.dataset.date;
|
|
openModalForDate(dateISO);
|
|
});
|
|
});
|
|
|
|
// Add vacation entry for a specific date
|
|
document.querySelectorAll('.btn-add-vacation').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const dateISO = btn.dataset.date;
|
|
await addSpecialEntry(dateISO, 'vacation');
|
|
});
|
|
});
|
|
|
|
// Add flextime entry for a specific date
|
|
document.querySelectorAll('.btn-add-flextime').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const dateISO = btn.dataset.date;
|
|
await addSpecialEntry(dateISO, 'flextime');
|
|
});
|
|
});
|
|
|
|
// Checkbox event listeners for bulk edit (existing entries)
|
|
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', () => {
|
|
toggleEntrySelection(parseInt(checkbox.dataset.id));
|
|
});
|
|
});
|
|
|
|
// Checkbox event listeners for bulk edit (empty days)
|
|
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
|
|
checkbox.addEventListener('change', () => {
|
|
toggleEmptyDaySelection(checkbox.dataset.date);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add a special entry (vacation or flextime) for a specific date
|
|
*/
|
|
async function addSpecialEntry(dateISO, entryType) {
|
|
const typeName = entryType === 'vacation' ? 'Urlaub' : 'Gleittag';
|
|
|
|
// Check if date is a public holiday
|
|
const dateObj = new Date(dateISO + 'T00:00:00');
|
|
const holidayName = getHolidayName(dateObj);
|
|
|
|
if (holidayName) {
|
|
showNotification(`❌ An Feiertagen (${holidayName}) kann kein ${typeName} eingetragen werden`, 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`${typeName} für ${formatDateDisplay(dateISO)} eintragen?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: dateISO,
|
|
entryType: entryType
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Fehler beim Erstellen');
|
|
}
|
|
|
|
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' : '✅ Gleittag eingetragen (8h Soll, 0h Ist)';
|
|
showNotification(message, 'success');
|
|
loadMonthlyView();
|
|
} catch (error) {
|
|
showNotification(error.message, 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and display entries
|
|
*/
|
|
async function loadEntries(fromDate = null, toDate = null) {
|
|
const entries = await fetchEntries(fromDate, toDate);
|
|
renderEntries(entries);
|
|
}
|
|
|
|
/**
|
|
* Load and display monthly view
|
|
*/
|
|
async function loadMonthlyView() {
|
|
currentView = 'monthly';
|
|
showMonthNavigation();
|
|
|
|
// First day of display month
|
|
const fromDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-01`;
|
|
|
|
// Last day of display month
|
|
const lastDay = new Date(displayYear, displayMonth + 1, 0).getDate();
|
|
const toDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
const entries = await fetchEntries(fromDate, toDate);
|
|
renderMonthlyView(entries);
|
|
updateMonthDisplay();
|
|
updateStatistics(entries);
|
|
updateBridgeDaysDisplay();
|
|
|
|
// Show/hide PDF export button based on whether month is complete
|
|
const today = new Date();
|
|
const isCurrentOrFutureMonth = displayYear > today.getFullYear() ||
|
|
(displayYear === today.getFullYear() && displayMonth >= today.getMonth());
|
|
|
|
const pdfButton = document.getElementById('btnExportPDF');
|
|
const pdfButtonMobile = document.getElementById('btnExportPDFMobile');
|
|
|
|
if (isCurrentOrFutureMonth) {
|
|
pdfButton.style.display = 'none';
|
|
if (pdfButtonMobile) pdfButtonMobile.style.display = 'none';
|
|
} else {
|
|
pdfButton.style.display = '';
|
|
if (pdfButtonMobile) pdfButtonMobile.style.display = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reload the current view (monthly or filter)
|
|
*/
|
|
async function reloadView() {
|
|
if (currentView === 'filter') {
|
|
const entries = await fetchEntries(currentFilterFrom, currentFilterTo);
|
|
renderEntries(entries);
|
|
} else {
|
|
await loadMonthlyView();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show month navigation controls
|
|
*/
|
|
function showMonthNavigation() {
|
|
const nav = document.getElementById('monthNavigation');
|
|
if (nav) nav.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Hide month navigation controls
|
|
*/
|
|
function hideMonthNavigation() {
|
|
const nav = document.getElementById('monthNavigation');
|
|
if (nav) nav.classList.add('hidden');
|
|
}
|
|
|
|
/**
|
|
* Calculate and update statistics for the current month
|
|
*/
|
|
async function updateStatistics(entries) {
|
|
const today = new Date();
|
|
const currentYear = displayYear;
|
|
const currentMonth = displayMonth;
|
|
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
|
|
|
|
// Count workdays (excluding weekends and holidays, only up to today)
|
|
let workdaysCount = 0;
|
|
let workdaysPassed = 0;
|
|
let totalWorkdaysInMonth = 0; // For statistics display
|
|
|
|
// Count vacation days to exclude from workdays
|
|
const vacationDays = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'vacation')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
// Count flextime days (they are workdays with 0 hours worked)
|
|
const flextimeDays = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'flextime')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
let futureFlextimeDays = 0; // Count future flextime days in current month
|
|
|
|
for (let day = 1; day <= lastDay; day++) {
|
|
const dateObj = new Date(currentYear, currentMonth, day);
|
|
const year = dateObj.getFullYear();
|
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
|
const dateISO = `${year}-${month}-${dayStr}`;
|
|
|
|
const isVacation = vacationDays.has(dateISO);
|
|
const isFlextime = flextimeDays.has(dateISO);
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
|
|
if (!isWeekendHoliday && !isVacation && !isFlextime) {
|
|
// Normal workday (excluding vacation and flextime days)
|
|
totalWorkdaysInMonth++;
|
|
workdaysCount++;
|
|
if (dateObj <= today) {
|
|
workdaysPassed++;
|
|
}
|
|
} else if (!isWeekendHoliday && !isVacation && isFlextime) {
|
|
// Flextime on a workday - still counts as workday in calendar
|
|
totalWorkdaysInMonth++;
|
|
workdaysCount++;
|
|
if (dateObj <= today) {
|
|
workdaysPassed++;
|
|
} else {
|
|
// Future flextime in current month
|
|
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
|
if (isCurrentMonth) {
|
|
futureFlextimeDays++;
|
|
}
|
|
}
|
|
} else if (isFlextime && isWeekendHoliday) {
|
|
// Flextime on weekend/holiday counts as additional workday
|
|
totalWorkdaysInMonth++;
|
|
if (new Date(dateISO) <= today) {
|
|
workdaysPassed++;
|
|
}
|
|
}
|
|
// Vacation days are excluded from all counts
|
|
}
|
|
|
|
// Calculate target hours (8h per workday passed, no reduction)
|
|
const targetHours = workdaysPassed * 8;
|
|
|
|
// Calculate actual hours worked (only up to today)
|
|
let actualHours = entries
|
|
.filter(e => new Date(e.date) <= today)
|
|
.reduce((sum, entry) => sum + entry.netHours, 0);
|
|
|
|
// Add currently running timer hours to actual hours (only for current month)
|
|
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
|
if (timerStartTime && isCurrentMonth) {
|
|
const now = Date.now();
|
|
let elapsedSeconds;
|
|
|
|
// Use same logic as timer display - respect pauses
|
|
if (isPaused) {
|
|
elapsedSeconds = pauseStartElapsed;
|
|
} else {
|
|
elapsedSeconds = Math.floor((now - timerStartTime) / 1000) - timerPausedDuration;
|
|
}
|
|
|
|
const elapsedHours = elapsedSeconds / 3600;
|
|
actualHours += elapsedHours;
|
|
}
|
|
|
|
// Calculate balance for current month
|
|
let balance = actualHours - targetHours;
|
|
|
|
// Subtract future flextime days from balance (they consume flextime)
|
|
balance -= (futureFlextimeDays * 8);
|
|
|
|
// Calculate previous month balance
|
|
const previousBalance = await calculatePreviousBalance();
|
|
|
|
// Total balance = previous balance + current month balance
|
|
const totalBalance = previousBalance + balance;
|
|
|
|
// Count actual work entries (excluding vacation/flextime, only up to today)
|
|
const workEntriesCount = entries.filter(e => {
|
|
const entryDate = new Date(e.date);
|
|
return entryDate <= today && (!e.entryType || e.entryType === 'work');
|
|
}).length;
|
|
|
|
// Update UI
|
|
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
|
|
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
|
|
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`;
|
|
|
|
// Show/hide flextime hint icons
|
|
const balanceFlextimeHint = document.getElementById('balanceFlextimeHint');
|
|
const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint');
|
|
|
|
if (futureFlextimeDays > 0) {
|
|
const tooltipText = `Inkl. ${futureFlextimeDays} geplanter Gleitzeittag${futureFlextimeDays > 1 ? 'e' : ''} (-${futureFlextimeDays * 8}h)`;
|
|
|
|
balanceFlextimeHint.classList.remove('hidden');
|
|
totalBalanceFlextimeHint.classList.remove('hidden');
|
|
|
|
// Set title attribute before re-initializing icons
|
|
balanceFlextimeHint.setAttribute('title', tooltipText);
|
|
totalBalanceFlextimeHint.setAttribute('title', tooltipText);
|
|
|
|
// Re-initialize icons
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Re-apply title after icon initialization (in case it was cleared)
|
|
balanceFlextimeHint.setAttribute('title', tooltipText);
|
|
totalBalanceFlextimeHint.setAttribute('title', tooltipText);
|
|
} else {
|
|
balanceFlextimeHint.classList.add('hidden');
|
|
totalBalanceFlextimeHint.classList.add('hidden');
|
|
}
|
|
|
|
// Current month balance
|
|
const balanceElement = document.getElementById('statBalance');
|
|
balanceElement.textContent = (balance >= 0 ? '+' : '') + balance.toFixed(1) + 'h';
|
|
if (balance > 0) {
|
|
balanceElement.className = 'text-2xl font-bold text-green-400';
|
|
} else if (balance < 0) {
|
|
balanceElement.className = 'text-2xl font-bold text-red-400';
|
|
} else {
|
|
balanceElement.className = 'text-2xl font-bold text-gray-100';
|
|
}
|
|
|
|
// Previous month balance
|
|
const prevBalanceElement = document.getElementById('statPreviousBalance');
|
|
prevBalanceElement.textContent = (previousBalance >= 0 ? '+' : '') + previousBalance.toFixed(1) + 'h';
|
|
|
|
// Total balance
|
|
const totalBalanceElement = document.getElementById('statTotalBalance');
|
|
totalBalanceElement.textContent = (totalBalance >= 0 ? '+' : '') + totalBalance.toFixed(1) + 'h';
|
|
if (totalBalance > 0) {
|
|
totalBalanceElement.className = 'text-3xl font-bold text-green-400';
|
|
} else if (totalBalance < 0) {
|
|
totalBalanceElement.className = 'text-3xl font-bold text-red-400';
|
|
} else {
|
|
totalBalanceElement.className = 'text-3xl font-bold text-gray-100';
|
|
}
|
|
|
|
// Update vacation statistics
|
|
await updateVacationStatistics();
|
|
}
|
|
|
|
/**
|
|
* Update vacation statistics for the year
|
|
*/
|
|
async function updateVacationStatistics() {
|
|
const currentYear = displayYear; // Use displayed year, not current calendar year
|
|
const today = new Date();
|
|
|
|
// Fetch all entries for the displayed year
|
|
const fromDate = `${currentYear}-01-01`;
|
|
const toDate = `${currentYear}-12-31`;
|
|
const allEntries = await fetchEntries(fromDate, toDate);
|
|
|
|
// Filter vacation entries
|
|
const vacationEntries = allEntries.filter(e => e.entryType === 'vacation');
|
|
|
|
// Calculate taken (past and today) and planned (future) vacation days
|
|
const vacationTaken = vacationEntries.filter(e => new Date(e.date) <= today).length;
|
|
const vacationPlanned = vacationEntries.filter(e => new Date(e.date) > today).length;
|
|
const vacationRemaining = totalVacationDays - vacationTaken - vacationPlanned;
|
|
|
|
// Update UI with dynamic year
|
|
const vacationLabel = document.getElementById('vacationYearLabel');
|
|
vacationLabel.innerHTML = `<i data-lucide="plane" class="w-4 h-4"></i> Urlaub ${currentYear}`;
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
document.getElementById('statVacationTaken').textContent = vacationTaken;
|
|
document.getElementById('statVacationPlanned').textContent = vacationPlanned;
|
|
document.getElementById('statVacationRemaining').textContent = `${vacationRemaining} / ${totalVacationDays}`;
|
|
}
|
|
|
|
/**
|
|
* Update bridge days display for current month
|
|
*/
|
|
function updateBridgeDaysDisplay() {
|
|
const container = document.getElementById('bridgeDaysContainer');
|
|
const list = document.getElementById('bridgeDaysList');
|
|
|
|
// Calculate bridge days for current month
|
|
const recommendations = calculateBridgeDays(displayYear, displayMonth, currentBundesland);
|
|
|
|
if (recommendations.length === 0) {
|
|
container.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
// Show container and populate list
|
|
container.classList.remove('hidden');
|
|
list.innerHTML = '';
|
|
|
|
recommendations.forEach((rec, index) => {
|
|
const startDate = new Date(rec.startDate);
|
|
const endDate = new Date(rec.endDate);
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'flex items-start gap-3 p-3 bg-gray-800/50 rounded-lg border border-cyan-600/20 hover:border-cyan-600/40 transition-colors';
|
|
|
|
// Format date range
|
|
const startStr = formatDateDisplay(rec.startDate);
|
|
const endStr = formatDateDisplay(rec.endDate);
|
|
|
|
// Create vacation days list
|
|
const vacDaysList = rec.vacationDays.map(d => {
|
|
const date = new Date(d);
|
|
return `${date.getDate()}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
|
|
}).join(', ');
|
|
|
|
item.innerHTML = `
|
|
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-cyan-600/20 flex items-center justify-center text-cyan-400 font-bold text-sm">
|
|
${Math.round(rec.ratio)}x
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-medium text-gray-200">
|
|
${startStr} - ${endStr}
|
|
</div>
|
|
<div class="text-xs text-gray-400 mt-1">
|
|
${rec.vacationDaysNeeded} Urlaubstag${rec.vacationDaysNeeded > 1 ? 'e' : ''} (${vacDaysList}) für ${rec.totalFreeDays} freie Tage
|
|
</div>
|
|
${rec.holidays.length > 0 ? `
|
|
<div class="text-xs text-cyan-400 mt-1">
|
|
${rec.holidays.join(', ')}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<button class="btn-add-bridge-days flex-shrink-0 px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white text-xs rounded transition-colors"
|
|
data-days='${JSON.stringify(rec.vacationDays)}'
|
|
title="Als Urlaub eintragen">
|
|
<i data-lucide="calendar-plus" class="w-3 h-3"></i>
|
|
</button>
|
|
`;
|
|
|
|
list.appendChild(item);
|
|
});
|
|
|
|
// Re-initialize icons
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Add event listeners for quick add buttons
|
|
document.querySelectorAll('.btn-add-bridge-days').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const days = JSON.parse(btn.dataset.days);
|
|
await addBridgeDaysAsVacation(days);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add bridge days as vacation entries
|
|
*/
|
|
async function addBridgeDaysAsVacation(days) {
|
|
let created = 0;
|
|
let skipped = 0;
|
|
|
|
for (const dateStr of days) {
|
|
try {
|
|
const response = await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: dateStr,
|
|
entryType: 'vacation'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
created++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating vacation entry:', error);
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
await reloadView();
|
|
showNotification(`✓ ${created} Urlaubstag${created > 1 ? 'e' : ''} eingetragen${skipped > 0 ? `, ${skipped} übersprungen` : ''}`, 'success');
|
|
}
|
|
|
|
/**
|
|
* Calculate balance from all previous months (starting from first month with entries)
|
|
*/
|
|
async function calculatePreviousBalance() {
|
|
const currentYear = displayYear;
|
|
const currentMonth = displayMonth;
|
|
|
|
// Find the first month with any entries by checking all entries
|
|
const allEntries = await fetchEntries();
|
|
if (allEntries.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Find earliest date
|
|
const earliestDate = allEntries.reduce((earliest, entry) => {
|
|
const entryDate = new Date(entry.date);
|
|
return !earliest || entryDate < earliest ? entryDate : earliest;
|
|
}, null);
|
|
|
|
if (!earliestDate) {
|
|
return 0;
|
|
}
|
|
|
|
const firstYear = earliestDate.getFullYear();
|
|
const firstMonth = earliestDate.getMonth();
|
|
|
|
// Calculate balance from first month to previous month (not including current displayed month)
|
|
let totalBalance = 0;
|
|
let checkYear = firstYear;
|
|
let checkMonth = firstMonth;
|
|
|
|
const today = new Date();
|
|
|
|
// Loop through all months from first entry until previous month of displayed month
|
|
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
|
|
const firstDay = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-01`;
|
|
const lastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
|
|
const lastDayStr = `${checkYear}-${String(checkMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
const entries = await fetchEntries(firstDay, lastDayStr);
|
|
|
|
// For past months, use full month. For current month (if displayed), limit to today
|
|
const monthEnd = new Date(checkYear, checkMonth + 1, 0);
|
|
const limitDate = monthEnd; // Always use full month for previous balance calculation
|
|
|
|
let workdaysPassed = 0;
|
|
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
|
|
|
|
// Count vacation days to exclude from workdays
|
|
const vacationDays = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'vacation')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
// Count flextime days (they are workdays with 0 hours worked)
|
|
const flextimeDays = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'flextime')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
for (let day = 1; day <= monthLastDay; day++) {
|
|
const dateObj = new Date(checkYear, checkMonth, day);
|
|
const year = dateObj.getFullYear();
|
|
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
|
const dateISO = `${year}-${month}-${dayStr}`;
|
|
|
|
if (!isWeekendOrHoliday(dateObj)) {
|
|
// Exclude vacation days from workdays count
|
|
if (!vacationDays.has(dateISO)) {
|
|
workdaysPassed++;
|
|
}
|
|
} else if (flextimeDays.has(dateISO)) {
|
|
// Flextime on weekend/holiday counts as workday
|
|
workdaysPassed++;
|
|
}
|
|
}
|
|
|
|
const targetHours = workdaysPassed * 8;
|
|
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
|
|
const monthBalance = actualHours - targetHours;
|
|
|
|
totalBalance += monthBalance;
|
|
|
|
// Move to next month
|
|
checkMonth++;
|
|
if (checkMonth > 11) {
|
|
checkMonth = 0;
|
|
checkYear++;
|
|
}
|
|
}
|
|
|
|
return totalBalance;
|
|
}
|
|
|
|
/**
|
|
* Open modal for adding/editing entry
|
|
*/
|
|
function openModal(entry = null) {
|
|
const modal = document.getElementById('entryModal');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const dateInput = document.getElementById('modalDate');
|
|
const startTimeInput = document.getElementById('modalStartTime');
|
|
const endTimeInput = document.getElementById('modalEndTime');
|
|
const pauseInput = document.getElementById('modalPause');
|
|
const locationInput = document.getElementById('modalLocation');
|
|
|
|
if (entry) {
|
|
// Edit mode
|
|
modalTitle.textContent = 'Eintrag bearbeiten';
|
|
currentEditingId = entry.id;
|
|
dateInput.value = formatDateDisplay(entry.date);
|
|
startTimeInput.value = entry.startTime;
|
|
endTimeInput.value = entry.endTime;
|
|
pauseInput.value = entry.pauseMinutes;
|
|
locationInput.value = entry.location || 'office';
|
|
updateLocationButtons(entry.location || 'office');
|
|
} else {
|
|
// Add mode
|
|
modalTitle.textContent = 'Neuer Eintrag';
|
|
currentEditingId = null;
|
|
dateInput.value = '';
|
|
startTimeInput.value = '';
|
|
endTimeInput.value = '';
|
|
pauseInput.value = '';
|
|
locationInput.value = 'office';
|
|
updateLocationButtons('office');
|
|
}
|
|
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Open modal with pre-filled date
|
|
*/
|
|
function openModalForDate(dateISO) {
|
|
const modal = document.getElementById('entryModal');
|
|
const modalTitle = document.getElementById('modalTitle');
|
|
const dateInput = document.getElementById('modalDate');
|
|
const startTimeInput = document.getElementById('modalStartTime');
|
|
const endTimeInput = document.getElementById('modalEndTime');
|
|
const pauseInput = document.getElementById('modalPause');
|
|
const locationInput = document.getElementById('modalLocation');
|
|
|
|
modalTitle.textContent = 'Neuer Eintrag';
|
|
currentEditingId = null;
|
|
dateInput.value = formatDateDisplay(dateISO);
|
|
startTimeInput.value = '09:00';
|
|
endTimeInput.value = '17:30';
|
|
pauseInput.value = '';
|
|
locationInput.value = 'office';
|
|
updateLocationButtons('office');
|
|
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Update location button states
|
|
*/
|
|
function updateLocationButtons(location) {
|
|
const officeBtn = document.getElementById('btnLocationOffice');
|
|
const homeBtn = document.getElementById('btnLocationHome');
|
|
|
|
if (location === 'home') {
|
|
officeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2';
|
|
homeBtn.className = 'flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm';
|
|
} else {
|
|
officeBtn.className = 'flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium flex items-center justify-center gap-2 shadow-sm';
|
|
homeBtn.className = 'flex-1 px-4 py-3 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium flex items-center justify-center gap-2';
|
|
}
|
|
|
|
// Reinitialize Lucide icons after DOM update
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close modal
|
|
*/
|
|
function closeModal() {
|
|
const modal = document.getElementById('entryModal');
|
|
modal.classList.add('hidden');
|
|
currentEditingId = null;
|
|
}
|
|
|
|
/**
|
|
* Handle form submission
|
|
*/
|
|
async function handleFormSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const date = document.getElementById('modalDate').value;
|
|
const startTime = document.getElementById('modalStartTime').value;
|
|
const endTime = document.getElementById('modalEndTime').value;
|
|
const pauseInput = document.getElementById('modalPause').value;
|
|
const location = document.getElementById('modalLocation').value;
|
|
|
|
// Convert empty string or "0" to null for auto-calculation
|
|
const pauseMinutes = (pauseInput === '' || pauseInput === '0' || parseInt(pauseInput) === 0) ? null : parseInt(pauseInput);
|
|
|
|
if (!date || !startTime || !endTime) {
|
|
showNotification('Bitte alle Felder ausfüllen', 'error');
|
|
return;
|
|
}
|
|
|
|
// Check if date is a public holiday
|
|
const dateISO = formatDateISO(date);
|
|
const dateObj = new Date(dateISO + 'T00:00:00');
|
|
const holidayName = getHolidayName(dateObj);
|
|
|
|
if (holidayName && !currentEditingId) {
|
|
showNotification(`❌ An Feiertagen (${holidayName}) können keine Arbeitszeiten eingetragen werden`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate max 10h net time
|
|
const netHours = calculateNetHours(startTime, endTime, pauseMinutes);
|
|
if (netHours > 10) {
|
|
showNotification('❌ Maximale Arbeitszeit überschritten! Netto-Arbeitszeit darf maximal 10,0h betragen.', '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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate net hours from times and pause
|
|
*/
|
|
function calculateNetHours(startTime, endTime, pauseMinutes) {
|
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
|
const [endHour, endMin] = endTime.split(':').map(Number);
|
|
|
|
const startMinutes = startHour * 60 + startMin;
|
|
const endMinutes = endHour * 60 + endMin;
|
|
|
|
let grossMinutes = endMinutes - startMinutes;
|
|
if (grossMinutes < 0) grossMinutes += 24 * 60; // Handle overnight
|
|
|
|
const grossHours = grossMinutes / 60;
|
|
|
|
// Calculate pause if not provided
|
|
let pause = pauseMinutes || 0;
|
|
if (pauseMinutes === null || pauseMinutes === undefined) {
|
|
if (grossHours >= 9) {
|
|
pause = 45;
|
|
} else if (grossHours >= 6) {
|
|
pause = 30;
|
|
}
|
|
}
|
|
|
|
const netHours = grossHours - (pause / 60);
|
|
return netHours;
|
|
}
|
|
|
|
/**
|
|
* Handle delete button click
|
|
*/
|
|
async function handleDelete(id) {
|
|
if (confirm('Möchten Sie diesen Eintrag wirklich löschen?')) {
|
|
const success = await deleteEntry(id);
|
|
if (success) {
|
|
showNotification('Eintrag gelöscht', 'success');
|
|
loadMonthlyView();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle quick time button click
|
|
* Calculates end time based on start time and target net hours
|
|
*/
|
|
function handleQuickTimeButton(netHours) {
|
|
const startTimeInput = document.getElementById('modalStartTime');
|
|
const endTimeInput = document.getElementById('modalEndTime');
|
|
|
|
// Get start time (default to 8:30 if empty)
|
|
let startTime = startTimeInput.value || '08:30';
|
|
startTimeInput.value = startTime;
|
|
|
|
// Parse start time
|
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
|
const startMinutes = startHour * 60 + startMin;
|
|
|
|
// Calculate required gross hours including pause
|
|
let pauseMinutes = 0;
|
|
let grossHours = netHours;
|
|
|
|
if (netHours >= 9) {
|
|
pauseMinutes = 45;
|
|
grossHours = netHours + (pauseMinutes / 60);
|
|
} else if (netHours >= 6) {
|
|
pauseMinutes = 30;
|
|
grossHours = netHours + (pauseMinutes / 60);
|
|
}
|
|
|
|
// Calculate end time
|
|
const endMinutes = startMinutes + (grossHours * 60);
|
|
const endHour = Math.floor(endMinutes / 60);
|
|
const endMin = Math.round(endMinutes % 60);
|
|
const endTime = `${String(endHour).padStart(2, '0')}:${String(endMin).padStart(2, '0')}`;
|
|
|
|
endTimeInput.value = endTime;
|
|
}
|
|
|
|
/**
|
|
* Auto-fill missing entries for current month with 8h net (9:00-17:30)
|
|
*/
|
|
async function handleAutoFillMonth() {
|
|
if (!confirm('Möchten Sie alle fehlenden Arbeitstage im aktuellen Monat mit 8h (9:00-17:30) ausfüllen?\n\nWochenenden und Feiertage werden übersprungen.')) {
|
|
return;
|
|
}
|
|
|
|
const today = new Date();
|
|
const currentYear = displayYear;
|
|
const currentMonth = displayMonth;
|
|
|
|
// Get last day of current month
|
|
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
|
|
|
|
// Get existing entries for the month
|
|
const fromDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-01`;
|
|
const toDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
const existingEntries = await fetchEntries(fromDate, toDate);
|
|
|
|
// Create a set of dates that already have entries
|
|
const existingDates = new Set(existingEntries.map(e => e.date));
|
|
|
|
let created = 0;
|
|
let skipped = 0;
|
|
|
|
// Iterate through all days in the month
|
|
for (let day = 1; day <= lastDay; day++) {
|
|
const dateObj = new Date(currentYear, currentMonth, day);
|
|
const dateISO = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
|
|
// Skip if entry already exists
|
|
if (existingDates.has(dateISO)) {
|
|
continue;
|
|
}
|
|
|
|
// Skip weekends and holidays
|
|
if (isWeekendOrHoliday(dateObj)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Skip future dates
|
|
if (dateObj > today) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
// Create entry: 9:00-17:30 (8h net with 30min pause)
|
|
const success = await createEntry(formatDateDisplay(dateISO), '09:00', '17:30', null);
|
|
if (success) {
|
|
created++;
|
|
}
|
|
}
|
|
|
|
// Reload view
|
|
await reloadView();
|
|
|
|
showNotification(`✓ ${created} Einträge erstellt, ${skipped} Tage übersprungen`, 'success');
|
|
}
|
|
|
|
// ============================================
|
|
// BULK EDIT
|
|
// ============================================
|
|
|
|
/**
|
|
* Toggle bulk edit mode
|
|
*/
|
|
function toggleBulkEditMode() {
|
|
bulkEditMode = !bulkEditMode;
|
|
selectedEntries.clear();
|
|
|
|
const bulkEditBar = document.getElementById('bulkEditBar');
|
|
const checkboxHeader = document.getElementById('checkboxHeader');
|
|
const toggleBtn = document.getElementById('btnToggleBulkEdit');
|
|
|
|
if (bulkEditMode) {
|
|
bulkEditBar.classList.remove('hidden');
|
|
checkboxHeader.classList.remove('hidden');
|
|
toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 font-medium shadow-sm hover:shadow';
|
|
toggleBtn.title = 'Mehrfachauswahl deaktivieren';
|
|
} else {
|
|
bulkEditBar.classList.add('hidden');
|
|
checkboxHeader.classList.add('hidden');
|
|
toggleBtn.className = 'inline-flex items-center gap-2 px-4 py-2.5 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 font-medium shadow-sm hover:shadow';
|
|
toggleBtn.title = 'Mehrfachauswahl aktivieren';
|
|
}
|
|
|
|
// Reload view to show/hide checkboxes
|
|
reloadView();
|
|
}
|
|
|
|
/**
|
|
* Toggle entry selection
|
|
*/
|
|
function toggleEntrySelection(id) {
|
|
if (selectedEntries.has(id)) {
|
|
selectedEntries.delete(id);
|
|
} else {
|
|
selectedEntries.add(id);
|
|
}
|
|
updateSelectedCount();
|
|
updateCheckboxes();
|
|
}
|
|
|
|
/**
|
|
* Toggle empty day selection (for dates without entries)
|
|
*/
|
|
function toggleEmptyDaySelection(date) {
|
|
if (selectedEntries.has(date)) {
|
|
selectedEntries.delete(date);
|
|
} else {
|
|
selectedEntries.add(date);
|
|
}
|
|
updateSelectedCount();
|
|
updateCheckboxes();
|
|
}
|
|
|
|
/**
|
|
* Select all visible entries and empty days
|
|
*/
|
|
function selectAllEntries() {
|
|
// Select existing entries
|
|
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
|
|
const id = parseInt(checkbox.dataset.id);
|
|
selectedEntries.add(id);
|
|
checkbox.checked = true;
|
|
});
|
|
|
|
// Select empty days
|
|
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
|
|
const date = checkbox.dataset.date;
|
|
selectedEntries.add(date);
|
|
checkbox.checked = true;
|
|
});
|
|
|
|
updateSelectedCount();
|
|
}
|
|
|
|
/**
|
|
* Deselect all entries
|
|
*/
|
|
function deselectAllEntries() {
|
|
selectedEntries.clear();
|
|
|
|
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
|
|
checkbox.checked = false;
|
|
});
|
|
|
|
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
|
|
checkbox.checked = false;
|
|
});
|
|
|
|
updateSelectedCount();
|
|
}
|
|
|
|
/**
|
|
* Update selected count display
|
|
*/
|
|
function updateSelectedCount() {
|
|
document.getElementById('selectedCount').textContent = `${selectedEntries.size} ausgewählt`;
|
|
|
|
// Update master checkbox state
|
|
const masterCheckbox = document.getElementById('masterCheckbox');
|
|
const allCheckboxes = document.querySelectorAll('.entry-checkbox, .empty-day-checkbox');
|
|
|
|
if (allCheckboxes.length === 0) {
|
|
masterCheckbox.checked = false;
|
|
masterCheckbox.indeterminate = false;
|
|
} else if (selectedEntries.size === allCheckboxes.length) {
|
|
masterCheckbox.checked = true;
|
|
masterCheckbox.indeterminate = false;
|
|
} else if (selectedEntries.size > 0) {
|
|
masterCheckbox.checked = false;
|
|
masterCheckbox.indeterminate = true;
|
|
} else {
|
|
masterCheckbox.checked = false;
|
|
masterCheckbox.indeterminate = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update checkbox states
|
|
*/
|
|
function updateCheckboxes() {
|
|
// Update entry checkboxes (ID-based)
|
|
document.querySelectorAll('.entry-checkbox').forEach(checkbox => {
|
|
const id = parseInt(checkbox.dataset.id);
|
|
checkbox.checked = selectedEntries.has(id);
|
|
});
|
|
|
|
// Update empty day checkboxes (date-based)
|
|
document.querySelectorAll('.empty-day-checkbox').forEach(checkbox => {
|
|
const date = checkbox.dataset.date;
|
|
checkbox.checked = selectedEntries.has(date);
|
|
});
|
|
|
|
updateSelectedCount();
|
|
}
|
|
|
|
/**
|
|
* Handle master checkbox toggle
|
|
*/
|
|
function handleMasterCheckboxToggle() {
|
|
const masterCheckbox = document.getElementById('masterCheckbox');
|
|
|
|
if (masterCheckbox.checked) {
|
|
selectAllEntries();
|
|
} else {
|
|
deselectAllEntries();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk set location to office
|
|
*/
|
|
async function bulkSetLocation(location) {
|
|
if (selectedEntries.size === 0) {
|
|
showNotification('Keine Einträge ausgewählt', 'error');
|
|
return;
|
|
}
|
|
|
|
const locationText = location === 'home' ? 'Home Office' : 'Präsenz';
|
|
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge auf "${locationText}" setzen?`)) {
|
|
return;
|
|
}
|
|
|
|
let updated = 0;
|
|
const entries = await fetchEntries();
|
|
|
|
for (const id of selectedEntries) {
|
|
const entry = entries.find(e => e.id === id);
|
|
if (entry) {
|
|
// Convert date from YYYY-MM-DD to DD.MM.YYYY for updateEntry
|
|
const formattedDate = formatDateDisplay(entry.date);
|
|
const success = await updateEntry(id, formattedDate, entry.startTime, entry.endTime, entry.pauseMinutes, location);
|
|
if (success) {
|
|
updated++;
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedEntries.clear();
|
|
updateSelectedCount(); // Update counter to 0
|
|
await reloadView();
|
|
showNotification(`✓ ${updated} Eintrag/Einträge aktualisiert`, 'success');
|
|
toggleBulkEditMode(); // Close bulk edit mode
|
|
}
|
|
|
|
/**
|
|
* Bulk delete entries
|
|
*/
|
|
async function bulkDeleteEntries() {
|
|
if (selectedEntries.size === 0) {
|
|
showNotification('Keine Einträge ausgewählt', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Möchten Sie wirklich ${selectedEntries.size} Eintrag/Einträge löschen?`)) {
|
|
return;
|
|
}
|
|
|
|
let deleted = 0;
|
|
for (const id of selectedEntries) {
|
|
const success = await deleteEntry(id);
|
|
if (success) {
|
|
deleted++;
|
|
}
|
|
}
|
|
|
|
selectedEntries.clear();
|
|
updateSelectedCount(); // Update counter to 0
|
|
await reloadView();
|
|
showNotification(`${deleted} Eintrag/Einträge gelöscht`, 'success');
|
|
toggleBulkEditMode(); // Close bulk edit mode
|
|
}
|
|
|
|
/**
|
|
* Generate PDF with common template
|
|
* @param {Object} options - PDF generation options
|
|
*/
|
|
async function generatePDF(options) {
|
|
const {
|
|
title = 'Zeiterfassung',
|
|
subtitle,
|
|
tableData,
|
|
statistics,
|
|
additionalInfo = {},
|
|
fileName
|
|
} = options;
|
|
|
|
const { targetHours, totalNetHours, balance } = statistics;
|
|
const { vacationDays = 0, flextimeDays = 0 } = additionalInfo;
|
|
|
|
// Get employee data from settings
|
|
const employeeName = await getSetting('employeeName') || '';
|
|
const employeeId = await getSetting('employeeId') || '';
|
|
|
|
// Get jsPDF from window
|
|
const { jsPDF } = window.jspdf;
|
|
|
|
// Create PDF
|
|
const doc = new jsPDF('p', 'mm', 'a4');
|
|
|
|
// Header with statistics
|
|
doc.setFillColor(15, 23, 42);
|
|
doc.rect(0, 0, 210, 35, 'F');
|
|
|
|
// Title and subtitle
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFontSize(16);
|
|
doc.setFont(undefined, 'bold');
|
|
doc.text(title, 15, 12, { align: 'left' });
|
|
|
|
doc.setFontSize(11);
|
|
doc.setFont(undefined, 'normal');
|
|
doc.text(subtitle, 195, 12, { align: 'right' });
|
|
|
|
// Employee info in second line
|
|
if (employeeName || employeeId) {
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(200, 200, 200);
|
|
let employeeInfo = '';
|
|
if (employeeName) employeeInfo += employeeName;
|
|
if (employeeId) {
|
|
if (employeeInfo) employeeInfo += ' | ';
|
|
employeeInfo += `Personal-Nr. ${employeeId}`;
|
|
}
|
|
doc.text(employeeInfo, 15, 19, { align: 'left' });
|
|
}
|
|
|
|
// Statistics - three columns in one line
|
|
const statsY = employeeName || employeeId ? 28 : 22;
|
|
|
|
// Soll
|
|
doc.setTextColor(180, 180, 180);
|
|
doc.setFontSize(7);
|
|
doc.text('SOLL-STUNDEN', 40, statsY - 3, { align: 'center' });
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFontSize(11);
|
|
doc.setFont(undefined, 'bold');
|
|
doc.text(`${targetHours.toFixed(1)}h`, 40, statsY + 3, { align: 'center' });
|
|
|
|
// Ist
|
|
doc.setTextColor(180, 180, 180);
|
|
doc.setFontSize(7);
|
|
doc.setFont(undefined, 'normal');
|
|
doc.text('IST-STUNDEN', 105, statsY - 3, { align: 'center' });
|
|
doc.setTextColor(255, 255, 255);
|
|
doc.setFontSize(11);
|
|
doc.setFont(undefined, 'bold');
|
|
doc.text(`${totalNetHours.toFixed(1)}h`, 105, statsY + 3, { align: 'center' });
|
|
|
|
// Saldo
|
|
doc.setTextColor(180, 180, 180);
|
|
doc.setFontSize(7);
|
|
doc.setFont(undefined, 'normal');
|
|
doc.text('SALDO', 170, statsY - 3, { align: 'center' });
|
|
if (balance >= 0) {
|
|
doc.setTextColor(34, 197, 94);
|
|
} else {
|
|
doc.setTextColor(239, 68, 68);
|
|
}
|
|
doc.setFontSize(11);
|
|
doc.setFont(undefined, 'bold');
|
|
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
|
|
|
|
// Additional info if needed (small, far right)
|
|
if (vacationDays > 0 || flextimeDays > 0) {
|
|
doc.setTextColor(150, 150, 150);
|
|
doc.setFontSize(7);
|
|
doc.setFont(undefined, 'normal');
|
|
let infoText = '';
|
|
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
|
|
if (flextimeDays > 0) {
|
|
if (infoText) infoText += ' ';
|
|
infoText += `Gleitzeit: ${flextimeDays}`;
|
|
}
|
|
doc.text(infoText, 195, statsY + 3, { align: 'right' });
|
|
}
|
|
|
|
// Table starts after header
|
|
let yPos = 37;
|
|
|
|
// Generate table
|
|
doc.autoTable({
|
|
startY: yPos,
|
|
head: [['Datum', 'Tag', 'Beginn', 'Ende', 'Pause', 'Typ', 'Netto', 'Abw.']],
|
|
body: tableData,
|
|
theme: 'grid',
|
|
tableWidth: 'auto',
|
|
headStyles: {
|
|
fillColor: [30, 41, 59],
|
|
textColor: [255, 255, 255],
|
|
fontSize: 9,
|
|
fontStyle: 'bold',
|
|
halign: 'center',
|
|
cellPadding: 2.5,
|
|
minCellHeight: 7
|
|
},
|
|
bodyStyles: {
|
|
fillColor: [248, 250, 252],
|
|
textColor: [15, 23, 42],
|
|
fontSize: 8,
|
|
cellPadding: 2,
|
|
minCellHeight: 6
|
|
},
|
|
alternateRowStyles: {
|
|
fillColor: [241, 245, 249]
|
|
},
|
|
columnStyles: {
|
|
0: { halign: 'center', cellWidth: 24 }, // Datum
|
|
1: { halign: 'center', cellWidth: 14 }, // Tag
|
|
2: { halign: 'center', cellWidth: 18 }, // Beginn
|
|
3: { halign: 'center', cellWidth: 18 }, // Ende
|
|
4: { halign: 'center', cellWidth: 18 }, // Pause
|
|
5: { halign: 'center', cellWidth: 26 }, // Ort
|
|
6: { halign: 'center', cellWidth: 18 }, // Netto
|
|
7: { halign: 'center', cellWidth: 18 } // Abweichung
|
|
},
|
|
didParseCell: function(data) {
|
|
if (data.column.index === 7 && data.section === 'body') {
|
|
const value = data.cell.raw;
|
|
if (value.startsWith('+')) {
|
|
data.cell.styles.textColor = [34, 197, 94];
|
|
data.cell.styles.fontStyle = 'bold';
|
|
} else if (value.startsWith('-') && value !== '-') {
|
|
data.cell.styles.textColor = [239, 68, 68];
|
|
data.cell.styles.fontStyle = 'bold';
|
|
}
|
|
}
|
|
},
|
|
margin: { left: 20, right: 20 } // Smaller margins for wider table
|
|
});
|
|
|
|
// Footer
|
|
const finalY = doc.lastAutoTable.finalY || yPos + 50;
|
|
if (finalY < 270) {
|
|
doc.setTextColor(156, 163, 175);
|
|
doc.setFontSize(8);
|
|
doc.text(`Erstellt am: ${new Date().toLocaleDateString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}`, 105, 285, { align: 'center' });
|
|
}
|
|
|
|
// Save PDF
|
|
doc.save(fileName);
|
|
}
|
|
|
|
/**
|
|
* Bulk export selected entries as PDF
|
|
*/
|
|
async function bulkExportPDF() {
|
|
if (selectedEntries.size === 0) {
|
|
showNotification('Keine Einträge ausgewählt', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { jsPDF } = window.jspdf;
|
|
|
|
// Get all entries and filter by selected IDs
|
|
const allEntries = await fetchEntries();
|
|
const selectedEntriesData = allEntries.filter(e => selectedEntries.has(e.id));
|
|
|
|
if (selectedEntriesData.length === 0) {
|
|
showNotification('Keine Einträge zum Exportieren gefunden', 'error');
|
|
return;
|
|
}
|
|
|
|
// Sort by date
|
|
selectedEntriesData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
|
|
// Get date range (parse dates correctly to avoid timezone issues)
|
|
const firstDateParts = selectedEntriesData[0].date.split('-');
|
|
const firstDate = new Date(parseInt(firstDateParts[0]), parseInt(firstDateParts[1]) - 1, parseInt(firstDateParts[2]));
|
|
const lastDateParts = selectedEntriesData[selectedEntriesData.length - 1].date.split('-');
|
|
const lastDate = new Date(parseInt(lastDateParts[0]), parseInt(lastDateParts[1]) - 1, parseInt(lastDateParts[2]));
|
|
const dateRange = selectedEntriesData[0].date === selectedEntriesData[selectedEntriesData.length - 1].date ?
|
|
formatDateDisplay(selectedEntriesData[0].date) :
|
|
`${formatDateDisplay(selectedEntriesData[0].date)} - ${formatDateDisplay(selectedEntriesData[selectedEntriesData.length - 1].date)}`;
|
|
|
|
// Get employee data from settings
|
|
const employeeName = await getSetting('employeeName') || '';
|
|
const employeeId = await getSetting('employeeId') || '';
|
|
|
|
// Calculate statistics based only on selected entries
|
|
const today = new Date();
|
|
let workdaysPassed = 0;
|
|
|
|
// Get unique dates from selected entries
|
|
const selectedDates = new Set(selectedEntriesData.map(e => e.date));
|
|
|
|
// Count vacation days from selected entries
|
|
const vacationDaysSet = new Set(
|
|
selectedEntriesData
|
|
.filter(e => e.entryType === 'vacation')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
// Count flextime days from selected entries
|
|
const flextimeDaysSet = new Set(
|
|
selectedEntriesData
|
|
.filter(e => e.entryType === 'flextime')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
// Count workdays based on selected entries only
|
|
selectedDates.forEach(dateISO => {
|
|
// Parse date correctly to avoid timezone issues
|
|
const [year, month, day] = dateISO.split('-').map(Number);
|
|
const dateObj = new Date(year, month - 1, day);
|
|
|
|
const isVacation = vacationDaysSet.has(dateISO);
|
|
const isFlextime = flextimeDaysSet.has(dateISO);
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
|
|
if (!isWeekendHoliday && !isVacation) {
|
|
// Normal workday
|
|
if (dateObj <= today) {
|
|
workdaysPassed++;
|
|
}
|
|
} else if (isFlextime && isWeekendHoliday) {
|
|
// Flextime on weekend/holiday counts as workday
|
|
if (dateObj <= today) {
|
|
workdaysPassed++;
|
|
}
|
|
}
|
|
// Vacation days are excluded from workday count
|
|
});
|
|
|
|
// Calculate total hours and days
|
|
let totalNetHours = 0;
|
|
let vacationDays = 0;
|
|
let flextimeDays = 0;
|
|
let workEntriesCount = 0;
|
|
|
|
selectedEntriesData.forEach(entry => {
|
|
const entryDate = new Date(entry.date);
|
|
if (entryDate <= today) {
|
|
if (!entry.entryType || entry.entryType === 'work') {
|
|
totalNetHours += entry.netHours;
|
|
workEntriesCount++;
|
|
} else if (entry.entryType === 'vacation') {
|
|
vacationDays++;
|
|
totalNetHours += entry.netHours;
|
|
} else if (entry.entryType === 'flextime') {
|
|
flextimeDays++;
|
|
// Only add flextime hours if it's on a weekend/holiday
|
|
// (otherwise it's already counted as workday hours)
|
|
const dateObj = new Date(entry.date);
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
if (isWeekendHoliday) {
|
|
totalNetHours += entry.netHours;
|
|
}
|
|
// If flextime on regular workday, don't add hours (already in work entries)
|
|
}
|
|
}
|
|
});
|
|
|
|
const targetHours = workdaysPassed * 8;
|
|
const balance = totalNetHours - targetHours;
|
|
|
|
// Build table data
|
|
const allDaysData = [];
|
|
const entriesMap = new Map(selectedEntriesData.map(e => [e.date, e]));
|
|
|
|
let currentDate = new Date(firstDate);
|
|
while (currentDate <= lastDate) {
|
|
const yearStr = currentDate.getFullYear();
|
|
const monthStr = String(currentDate.getMonth() + 1).padStart(2, '0');
|
|
const dayStr = String(currentDate.getDate()).padStart(2, '0');
|
|
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
|
|
const formattedDate = formatDateDisplay(dateISO);
|
|
const weekday = currentDate.toLocaleDateString('de-DE', { weekday: 'short' });
|
|
|
|
const isWeekendHoliday = isWeekendOrHoliday(currentDate);
|
|
const entry = entriesMap.get(dateISO);
|
|
|
|
if (entry) {
|
|
// Entry exists - use actual data
|
|
const deviation = entry.netHours - 8.0;
|
|
let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h';
|
|
|
|
let locationText = '';
|
|
let startTime = entry.startTime;
|
|
let endTime = entry.endTime;
|
|
let pauseText = entry.pauseMinutes + ' min';
|
|
let netHoursText = entry.netHours.toFixed(2) + 'h';
|
|
|
|
if (entry.entryType === 'vacation') {
|
|
locationText = 'Urlaub';
|
|
startTime = '-';
|
|
endTime = '-';
|
|
pauseText = '-';
|
|
netHoursText = '-';
|
|
deviationStr = '-';
|
|
} else if (entry.entryType === 'flextime') {
|
|
locationText = 'Gleittag';
|
|
startTime = '-';
|
|
endTime = '-';
|
|
pauseText = '-';
|
|
} else {
|
|
locationText = entry.location === 'home' ? 'Home' : 'Office';
|
|
}
|
|
|
|
allDaysData.push([
|
|
formattedDate,
|
|
weekday,
|
|
startTime,
|
|
endTime,
|
|
pauseText,
|
|
locationText,
|
|
netHoursText,
|
|
deviationStr
|
|
]);
|
|
} else if (isWeekendHoliday) {
|
|
// Weekend or holiday without entry
|
|
const holidayName = getHolidayName(currentDate);
|
|
const dayOfWeek = currentDate.getDay();
|
|
const isWeekendDay = dayOfWeek === 0 || dayOfWeek === 6;
|
|
|
|
let dayType = '';
|
|
if (holidayName) {
|
|
dayType = 'Feiertag';
|
|
} else if (isWeekendDay) {
|
|
dayType = 'Wochenende';
|
|
}
|
|
|
|
allDaysData.push([
|
|
formattedDate,
|
|
weekday,
|
|
'-',
|
|
'-',
|
|
'-',
|
|
dayType,
|
|
'-',
|
|
'-'
|
|
]);
|
|
}
|
|
// Skip regular workdays without entries
|
|
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
// Generate PDF using common template
|
|
const fileName = `Zeiterfassung_${selectedEntriesData[0].date}_${selectedEntriesData[selectedEntriesData.length - 1].date}.pdf`;
|
|
await generatePDF({
|
|
title: 'Zeiterfassung',
|
|
subtitle: dateRange,
|
|
tableData: allDaysData,
|
|
statistics: { targetHours, totalNetHours, balance },
|
|
additionalInfo: { vacationDays, flextimeDays },
|
|
fileName
|
|
});
|
|
|
|
showNotification(`PDF mit ${allDaysData.length} Tag(en) erstellt`, 'success');
|
|
} catch (error) {
|
|
console.error('Error exporting PDF:', error);
|
|
showNotification('Fehler beim PDF-Export', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bulk set vacation entries
|
|
*/
|
|
async function bulkSetVacation() {
|
|
if (selectedEntries.size === 0) {
|
|
showNotification('Keine Einträge ausgewählt', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Urlaub eintragen?`)) {
|
|
return;
|
|
}
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
const entries = await fetchEntries();
|
|
|
|
for (const item of selectedEntries) {
|
|
// Check if it's an ID (number) or date (string)
|
|
if (typeof item === 'number') {
|
|
// Update existing entry to vacation
|
|
const entry = entries.find(e => e.id === item);
|
|
if (entry) {
|
|
// Skip weekends/holidays
|
|
const dateObj = new Date(entry.date);
|
|
if (isWeekendOrHoliday(dateObj)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/entries/${item}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: entry.date,
|
|
entryType: 'vacation'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
updated++;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating entry:', error);
|
|
}
|
|
}
|
|
} else {
|
|
// Create new vacation entry for empty day (item is date string)
|
|
// Skip weekends/holidays
|
|
const dateObj = new Date(item);
|
|
if (isWeekendOrHoliday(dateObj)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: item,
|
|
entryType: 'vacation'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
created++;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating vacation entry:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedEntries.clear();
|
|
updateSelectedCount(); // Update counter to 0
|
|
await reloadView();
|
|
|
|
const message = skipped > 0
|
|
? `✓ ${created} Urlaub neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen`
|
|
: `✓ ${created} Urlaub neu eingetragen, ${updated} Einträge geändert`;
|
|
showNotification(message, 'success');
|
|
toggleBulkEditMode(); // Close bulk edit mode
|
|
}
|
|
|
|
/**
|
|
* Bulk set flextime entries
|
|
*/
|
|
async function bulkSetFlextime() {
|
|
if (selectedEntries.size === 0) {
|
|
showNotification('Keine Einträge ausgewählt', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm(`Möchten Sie ${selectedEntries.size} Eintrag/Einträge als Gleitzeit eintragen?`)) {
|
|
return;
|
|
}
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
const entries = await fetchEntries();
|
|
|
|
for (const item of selectedEntries) {
|
|
// Check if it's an ID (number) or date (string)
|
|
if (typeof item === 'number') {
|
|
// Update existing entry to flextime
|
|
const entry = entries.find(e => e.id === item);
|
|
if (entry) {
|
|
// Skip weekends/holidays
|
|
const dateObj = new Date(entry.date);
|
|
if (isWeekendOrHoliday(dateObj)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/entries/${item}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: entry.date,
|
|
entryType: 'flextime'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
updated++;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating entry:', error);
|
|
}
|
|
}
|
|
} else {
|
|
// Create new flextime entry for empty day (item is date string)
|
|
// Skip weekends/holidays
|
|
const dateObj = new Date(item);
|
|
if (isWeekendOrHoliday(dateObj)) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
date: item,
|
|
entryType: 'flextime'
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
created++;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating flextime entry:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedEntries.clear();
|
|
updateSelectedCount(); // Update counter to 0
|
|
await reloadView();
|
|
|
|
const message = skipped > 0
|
|
? `✓ ${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert, ${skipped} Wochenenden/Feiertage übersprungen`
|
|
: `✓ ${created} Gleitzeit neu eingetragen, ${updated} Einträge geändert`;
|
|
showNotification(message, 'success');
|
|
toggleBulkEditMode(); // Close bulk edit mode
|
|
}
|
|
|
|
// ============================================
|
|
// BUNDESLAND / SETTINGS
|
|
// ============================================
|
|
|
|
/**
|
|
* Handle Bundesland change
|
|
*/
|
|
async function handleBundeslandChange(event) {
|
|
const newBundesland = event.target.value;
|
|
const oldBundesland = currentBundesland;
|
|
|
|
// Check for conflicts with existing entries first
|
|
const entries = await fetchEntries();
|
|
const conflicts = [];
|
|
|
|
// Get old and new holidays for comparison
|
|
const currentYear = new Date().getFullYear();
|
|
const years = [currentYear - 1, currentYear, currentYear + 1];
|
|
|
|
const oldHolidays = new Set();
|
|
const newHolidays = new Set();
|
|
|
|
years.forEach(year => {
|
|
getPublicHolidays(year, oldBundesland).forEach(h => {
|
|
// Convert to YYYY-MM-DD format for comparison
|
|
const year = h.date.getFullYear();
|
|
const month = String(h.date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(h.date.getDate()).padStart(2, '0');
|
|
oldHolidays.add(`${year}-${month}-${day}`);
|
|
});
|
|
getPublicHolidays(year, newBundesland).forEach(h => {
|
|
// Convert to YYYY-MM-DD format for comparison
|
|
const year = h.date.getFullYear();
|
|
const month = String(h.date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(h.date.getDate()).padStart(2, '0');
|
|
const dateKey = `${year}-${month}-${day}`;
|
|
newHolidays.add(dateKey);
|
|
});
|
|
});
|
|
|
|
// Create a map of new holidays with their names
|
|
const newHolidayMap = new Map();
|
|
years.forEach(year => {
|
|
getPublicHolidays(year, newBundesland).forEach(h => {
|
|
const year = h.date.getFullYear();
|
|
const month = String(h.date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(h.date.getDate()).padStart(2, '0');
|
|
const dateKey = `${year}-${month}-${day}`;
|
|
newHolidayMap.set(dateKey, h.name);
|
|
});
|
|
});
|
|
|
|
// Find dates that are holidays in new state but not in old state, and have entries
|
|
entries.forEach(entry => {
|
|
if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) {
|
|
conflicts.push({
|
|
date: entry.date,
|
|
displayDate: formatDateDisplay(entry.date),
|
|
holidayName: newHolidayMap.get(entry.date)
|
|
});
|
|
}
|
|
});
|
|
|
|
// If no conflicts, change directly without warning
|
|
if (conflicts.length === 0) {
|
|
await performBundeslandChange(newBundesland, oldBundesland, event);
|
|
return;
|
|
}
|
|
|
|
// Show warning with backup recommendation only if conflicts exist
|
|
const bundeslandNames = {
|
|
'BW': 'Baden-Württemberg',
|
|
'BY': 'Bayern',
|
|
'BE': 'Berlin',
|
|
'BB': 'Brandenburg',
|
|
'HB': 'Bremen',
|
|
'HH': 'Hamburg',
|
|
'HE': 'Hessen',
|
|
'MV': 'Mecklenburg-Vorpommern',
|
|
'NI': 'Niedersachsen',
|
|
'NW': 'Nordrhein-Westfalen',
|
|
'RP': 'Rheinland-Pfalz',
|
|
'SL': 'Saarland',
|
|
'SN': 'Sachsen',
|
|
'ST': 'Sachsen-Anhalt',
|
|
'SH': 'Schleswig-Holstein',
|
|
'TH': 'Thüringen'
|
|
};
|
|
|
|
const conflictList = conflicts.map(c => `<li class="text-sm">• ${c.displayDate} (<span class="text-yellow-300">${c.holidayName}</span>)</li>`).join('');
|
|
|
|
// Create custom modal for bundesland change confirmation
|
|
const modalHTML = `
|
|
<div id="bundeslandWarningModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" style="display: flex;">
|
|
<div class="bg-gray-800 rounded-xl shadow-2xl p-6 max-w-md w-full mx-4 border border-yellow-600">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<i data-lucide="alert-triangle" class="w-8 h-8 text-yellow-500"></i>
|
|
<h3 class="text-xl font-bold text-yellow-500">Achtung: Konflikte gefunden</h3>
|
|
</div>
|
|
<div class="text-gray-300 mb-6 space-y-3">
|
|
<p>Sie möchten das Bundesland von <strong>${bundeslandNames[oldBundesland]}</strong> auf <strong>${bundeslandNames[newBundesland]}</strong> ändern.</p>
|
|
<p class="text-yellow-400"><strong>Warnung:</strong> Die folgenden Tage werden zu Feiertagen und haben bereits Einträge:</p>
|
|
<ul class="bg-gray-900 rounded-lg p-3 max-h-40 overflow-y-auto">
|
|
${conflictList}
|
|
</ul>
|
|
<p class="text-sm text-gray-400">Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.</p>
|
|
<div class="bg-gray-900 border border-blue-600 rounded-lg p-3 mt-4">
|
|
<p class="text-blue-400 text-sm mb-2"><i data-lucide="info" class="w-4 h-4 inline mr-1"></i> Wir empfehlen ein Backup vor der Änderung:</p>
|
|
<button id="quickBackupBtn" class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
|
|
<i data-lucide="download" class="w-4 h-4"></i>
|
|
Jetzt Backup erstellen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3">
|
|
<button id="cancelBundeslandChange" class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors">
|
|
Abbrechen
|
|
</button>
|
|
<button id="confirmBundeslandChange" class="flex-1 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors font-semibold">
|
|
Trotzdem ändern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Insert modal into DOM
|
|
const modalContainer = document.createElement('div');
|
|
modalContainer.innerHTML = modalHTML;
|
|
document.body.appendChild(modalContainer);
|
|
|
|
// Initialize icons in modal
|
|
if (typeof lucide !== 'undefined' && lucide.createIcons) {
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// Handle backup button
|
|
document.getElementById('quickBackupBtn').addEventListener('click', async () => {
|
|
await exportDatabase();
|
|
showNotification('✓ Backup erstellt', 'success');
|
|
});
|
|
|
|
// Handle cancel
|
|
document.getElementById('cancelBundeslandChange').addEventListener('click', () => {
|
|
event.target.value = oldBundesland;
|
|
document.getElementById('bundeslandWarningModal').remove();
|
|
});
|
|
|
|
// Handle confirm
|
|
document.getElementById('confirmBundeslandChange').addEventListener('click', async () => {
|
|
document.getElementById('bundeslandWarningModal').remove();
|
|
await performBundeslandChange(newBundesland, oldBundesland, event);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Perform the actual bundesland change after confirmation
|
|
*/
|
|
async function performBundeslandChange(newBundesland, oldBundesland, event) {
|
|
const bundeslandNames = {
|
|
'BW': 'Baden-Württemberg',
|
|
'BY': 'Bayern',
|
|
'BE': 'Berlin',
|
|
'BB': 'Brandenburg',
|
|
'HB': 'Bremen',
|
|
'HH': 'Hamburg',
|
|
'HE': 'Hessen',
|
|
'MV': 'Mecklenburg-Vorpommern',
|
|
'NI': 'Niedersachsen',
|
|
'NW': 'Nordrhein-Westfalen',
|
|
'RP': 'Rheinland-Pfalz',
|
|
'SL': 'Saarland',
|
|
'SN': 'Sachsen',
|
|
'ST': 'Sachsen-Anhalt',
|
|
'SH': 'Schleswig-Holstein',
|
|
'TH': 'Thüringen'
|
|
};
|
|
|
|
// Update state and save
|
|
currentBundesland = newBundesland;
|
|
await setSetting('bundesland', newBundesland);
|
|
|
|
// Reload view to show updated holidays
|
|
await reloadView();
|
|
|
|
showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success');
|
|
}
|
|
|
|
/**
|
|
* Load saved settings
|
|
*/
|
|
async function loadSettings() {
|
|
const savedBundesland = await getSetting('bundesland');
|
|
if (savedBundesland) {
|
|
currentBundesland = savedBundesland;
|
|
document.getElementById('bundeslandSelect').value = savedBundesland;
|
|
}
|
|
|
|
const savedVacationDays = await getSetting('vacationDays');
|
|
if (savedVacationDays) {
|
|
totalVacationDays = parseInt(savedVacationDays);
|
|
document.getElementById('vacationDaysInput').value = totalVacationDays;
|
|
}
|
|
|
|
const savedEmployeeName = await getSetting('employeeName');
|
|
if (savedEmployeeName) {
|
|
document.getElementById('employeeName').value = savedEmployeeName;
|
|
}
|
|
|
|
const savedEmployeeId = await getSetting('employeeId');
|
|
if (savedEmployeeId) {
|
|
document.getElementById('employeeId').value = savedEmployeeId;
|
|
}
|
|
|
|
const savedCompanyHoliday = await getSetting('companyHoliday');
|
|
if (savedCompanyHoliday) {
|
|
companyHolidayPreference = savedCompanyHoliday;
|
|
if (savedCompanyHoliday === 'christmas') {
|
|
document.getElementById('companyHolidayChristmas').checked = true;
|
|
} else if (savedCompanyHoliday === 'newyearseve') {
|
|
document.getElementById('companyHolidayNewYear').checked = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and display version info
|
|
*/
|
|
async function loadVersionInfo() {
|
|
try {
|
|
const response = await fetch('/api/version');
|
|
if (!response.ok) return;
|
|
|
|
const versionData = await response.json();
|
|
const versionEl = document.getElementById('versionInfo');
|
|
|
|
if (versionEl && versionData.commit) {
|
|
const shortHash = versionData.commit.substring(0, 7);
|
|
versionEl.textContent = shortHash !== 'dev' ? shortHash : 'dev';
|
|
versionEl.title = `Commit: ${versionData.commit}\nBuild: ${versionData.buildDate}`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not load version info:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle vacation days input change
|
|
*/
|
|
async function handleVacationDaysChange(event) {
|
|
const newValue = parseInt(event.target.value);
|
|
|
|
if (isNaN(newValue) || newValue < 0 || newValue > 50) {
|
|
showNotification('Bitte geben Sie eine gültige Anzahl (0-50) ein', 'error');
|
|
event.target.value = totalVacationDays;
|
|
return;
|
|
}
|
|
|
|
// Check for conflicts: More vacation days already taken/planned than new value?
|
|
const currentYear = new Date().getFullYear();
|
|
const fromDate = `${currentYear}-01-01`;
|
|
const toDate = `${currentYear}-12-31`;
|
|
|
|
try {
|
|
const allEntries = await fetchEntries(fromDate, toDate);
|
|
const vacationCount = allEntries.filter(e => e.entryType === 'vacation').length;
|
|
|
|
if (vacationCount > newValue) {
|
|
// Show warning
|
|
const difference = vacationCount - newValue;
|
|
const confirmed = confirm(
|
|
`⚠️ Warnung: Konflikt erkannt!\n\n` +
|
|
`Sie haben bereits ${vacationCount} Urlaubstage für ${currentYear} eingetragen.\n` +
|
|
`Der neue Wert von ${newValue} Tag${newValue !== 1 ? 'en' : ''} ist ${difference} Tag${difference > 1 ? 'e' : ''} zu niedrig.\n\n` +
|
|
`Möchten Sie den Wert trotzdem auf ${newValue} setzen?`
|
|
);
|
|
|
|
if (!confirmed) {
|
|
event.target.value = totalVacationDays;
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking vacation conflicts:', error);
|
|
}
|
|
|
|
totalVacationDays = newValue;
|
|
await setSetting('vacationDays', newValue.toString());
|
|
await updateVacationStatistics();
|
|
showNotification(`✓ Urlaubstage auf ${newValue} pro Jahr gesetzt`, 'success');
|
|
}
|
|
|
|
/**
|
|
* Handle company holiday preference change
|
|
*/
|
|
async function handleCompanyHolidayChange(event) {
|
|
const newPreference = event.target.value;
|
|
|
|
// Check if there are any entries on the affected dates
|
|
const currentYear = new Date().getFullYear();
|
|
const years = [currentYear - 1, currentYear, currentYear + 1];
|
|
|
|
let affectedDate, affectedDateDisplay, holidayName;
|
|
|
|
if (newPreference === 'christmas') {
|
|
affectedDate = '12-24';
|
|
affectedDateDisplay = '24.12.';
|
|
holidayName = 'Heiligabend';
|
|
} else {
|
|
affectedDate = '12-31';
|
|
affectedDateDisplay = '31.12.';
|
|
holidayName = 'Silvester';
|
|
}
|
|
|
|
// Check for conflicts across multiple years
|
|
const conflicts = [];
|
|
try {
|
|
const allEntries = await fetchEntries();
|
|
|
|
years.forEach(year => {
|
|
const dateToCheck = `${year}-${affectedDate}`;
|
|
const hasEntry = allEntries.some(e => e.date === dateToCheck);
|
|
|
|
if (hasEntry) {
|
|
conflicts.push(`${affectedDateDisplay}${year}`);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error checking conflicts:', error);
|
|
}
|
|
|
|
// Show warning if there are existing entries on the new holiday date
|
|
if (conflicts.length > 0) {
|
|
const confirmed = confirm(
|
|
`⚠️ Hinweis: Auf ${holidayName} existieren bereits Einträge:\n\n` +
|
|
conflicts.join(', ') + '\n\n' +
|
|
`Diese Tage werden nun als betriebsfrei markiert.\n\n` +
|
|
`Möchten Sie fortfahren?`
|
|
);
|
|
|
|
if (!confirmed) {
|
|
// Revert the radio button selection
|
|
if (newPreference === 'christmas') {
|
|
document.getElementById('companyHolidayNewYear').checked = true;
|
|
} else {
|
|
document.getElementById('companyHolidayChristmas').checked = true;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update setting
|
|
companyHolidayPreference = newPreference;
|
|
await setSetting('companyHoliday', newPreference);
|
|
|
|
// Reload view to show updated holidays
|
|
await reloadView();
|
|
|
|
const holidayLabel = newPreference === 'christmas' ? 'Heiligabend (24.12.)' : 'Silvester (31.12.)';
|
|
showNotification(`✓ Betriebsfrei auf ${holidayLabel} gesetzt`, 'success');
|
|
}
|
|
|
|
// ============================================
|
|
// INLINE EDITING
|
|
// ============================================
|
|
|
|
/**
|
|
* Attach inline edit listeners to all editable cells
|
|
*/
|
|
function attachInlineEditListeners() {
|
|
document.querySelectorAll('.editable-cell').forEach(cell => {
|
|
cell.addEventListener('click', handleCellClick);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle cell click for inline editing
|
|
*/
|
|
function handleCellClick(e) {
|
|
const cell = e.target;
|
|
|
|
// Prevent editing if already editing
|
|
if (cell.classList.contains('editing')) {
|
|
return;
|
|
}
|
|
|
|
const field = cell.dataset.field;
|
|
const id = parseInt(cell.dataset.id);
|
|
const currentValue = cell.dataset.value;
|
|
|
|
// Create input
|
|
cell.classList.add('editing');
|
|
const originalContent = cell.innerHTML;
|
|
|
|
let input;
|
|
let picker;
|
|
|
|
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 - use Flatpickr
|
|
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();
|
|
|
|
// Initialize Flatpickr for time fields
|
|
if (field === 'startTime' || field === 'endTime') {
|
|
picker = flatpickr(input, {
|
|
enableTime: true,
|
|
noCalendar: true,
|
|
dateFormat: 'H:i',
|
|
time_24hr: true,
|
|
minuteIncrement: 15,
|
|
allowInput: true,
|
|
locale: 'de',
|
|
defaultHour: parseInt(currentValue.split(':')[0]) || 9,
|
|
defaultMinute: parseInt(currentValue.split(':')[1]) || 0,
|
|
onOpen: function(selectedDates, dateStr, instance) {
|
|
if (currentValue && currentValue.match(/^\d{1,2}:\d{2}$/)) {
|
|
instance.setDate(currentValue, false);
|
|
}
|
|
},
|
|
onClose: function(selectedDates, dateStr, instance) {
|
|
if (dateStr) {
|
|
input.value = dateStr;
|
|
}
|
|
// Trigger save after picker closes
|
|
setTimeout(() => saveEdit(), 100);
|
|
}
|
|
});
|
|
|
|
// Open picker immediately
|
|
setTimeout(() => picker.open(), 50);
|
|
} else {
|
|
input.select();
|
|
}
|
|
|
|
// Save on blur or Enter
|
|
const saveEdit = async () => {
|
|
// Destroy picker if exists
|
|
if (picker) {
|
|
picker.destroy();
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Validate max 10h net time
|
|
const netHours = calculateNetHours(values.startTime, values.endTime, pauseToSend);
|
|
if (netHours > 10) {
|
|
showNotification('❌ Maximale Arbeitszeit überschritten! Netto-Arbeitszeit darf maximal 10,0h betragen.', 'error');
|
|
cell.classList.remove('editing');
|
|
cell.innerHTML = originalContent;
|
|
return;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
};
|
|
|
|
// Event listeners - only if not a time picker
|
|
if (field === 'pauseMinutes') {
|
|
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;
|
|
}
|
|
});
|
|
} else {
|
|
// For time fields, save is handled by Flatpickr onClose
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
if (picker) picker.destroy();
|
|
cell.classList.remove('editing');
|
|
cell.innerHTML = originalContent;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle filter button click
|
|
*/
|
|
function handleFilter() {
|
|
const fromValue = document.getElementById('filterFrom').value;
|
|
const toValue = document.getElementById('filterTo').value;
|
|
|
|
const fromDate = fromValue ? formatDateISO(fromValue) : null;
|
|
const toDate = toValue ? formatDateISO(toValue) : null;
|
|
|
|
currentView = 'filter';
|
|
currentFilterFrom = fromDate;
|
|
currentFilterTo = toDate;
|
|
hideMonthNavigation();
|
|
|
|
// Hide bridge days recommendations in filter view
|
|
const bridgeDaysContainer = document.getElementById('bridgeDaysContainer');
|
|
if (bridgeDaysContainer) {
|
|
bridgeDaysContainer.classList.add('hidden');
|
|
}
|
|
|
|
loadEntries(fromDate, toDate);
|
|
}
|
|
|
|
/**
|
|
* Handle clear filter button click
|
|
*/
|
|
function handleClearFilter() {
|
|
document.getElementById('filterFrom').value = '';
|
|
document.getElementById('filterTo').value = '';
|
|
currentView = 'monthly';
|
|
currentFilterFrom = null;
|
|
currentFilterTo = null;
|
|
loadMonthlyView();
|
|
}
|
|
|
|
/**
|
|
* Handle export button click
|
|
*/
|
|
async function handleExport(onlyDeviations = false) {
|
|
const fromValue = document.getElementById('filterFrom').value;
|
|
const toValue = document.getElementById('filterTo').value;
|
|
|
|
// Validate that both dates are set
|
|
if (!fromValue || !toValue) {
|
|
showNotification('Bitte beide Datumsfelder ausfüllen', 'error');
|
|
return;
|
|
}
|
|
|
|
const fromDate = formatDateISO(fromValue);
|
|
const toDate = formatDateISO(toValue);
|
|
|
|
try {
|
|
// Fetch entries for the date range
|
|
let entries = await fetchEntries(fromDate, toDate);
|
|
|
|
// Filter entries if only deviations are requested
|
|
if (onlyDeviations) {
|
|
entries = entries.filter(entry => Math.abs(entry.netHours - 8.0) > 0.01);
|
|
}
|
|
|
|
// Check if there are any entries
|
|
if (entries.length === 0) {
|
|
showNotification(
|
|
onlyDeviations ?
|
|
'Keine Abweichungen vom 8h-Standard im gewählten Zeitraum.' :
|
|
'Keine Einträge im gewählten Zeitraum.',
|
|
'error'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Create CSV content
|
|
const csvHeader = 'Datum;Beginn;Ende;Pause (min);Netto (h);Arbeitsort;Abweichung (h)\n';
|
|
const csvRows = entries.map(entry => {
|
|
const deviation = entry.netHours - 8.0;
|
|
const deviationStr = (deviation >= 0 ? '+' : '') + deviation.toFixed(2);
|
|
const locationText = entry.location === 'home' ? 'Home' : 'Büro';
|
|
|
|
return `${entry.date};${entry.startTime};${entry.endTime};${entry.pauseMinutes};${entry.netHours.toFixed(2)};${locationText};${deviationStr}`;
|
|
}).join('\n');
|
|
|
|
const csvContent = csvHeader + csvRows;
|
|
|
|
// Create download link
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const fileName = onlyDeviations ?
|
|
`zeiterfassung_abweichungen_${fromDate}_${toDate}.csv` :
|
|
`zeiterfassung_${fromDate}_${toDate}.csv`;
|
|
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', fileName);
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
const message = onlyDeviations ?
|
|
`${entries.length} Abweichung(en) exportiert` :
|
|
`${entries.length} Eintrag/Einträge exportiert`;
|
|
|
|
showNotification(message, 'success');
|
|
} catch (error) {
|
|
console.error('Error exporting CSV:', error);
|
|
showNotification('Fehler beim CSV-Export', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export current month as PDF
|
|
*/
|
|
async function handleExportPDF() {
|
|
try {
|
|
const { jsPDF } = window.jspdf;
|
|
|
|
// Get current month data
|
|
const year = displayYear;
|
|
const month = displayMonth + 1; // displayMonth is 0-indexed, we need 1-12
|
|
const monthName = new Date(year, displayMonth).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
|
|
|
// Get first and last day of month in YYYY-MM-DD format
|
|
const currentYear = year;
|
|
const currentMonth = displayMonth; // Keep 0-indexed for Date constructor
|
|
const lastDay = new Date(currentYear, currentMonth + 1, 0).getDate();
|
|
const fromDate = `${year}-${String(month).padStart(2, '0')}-01`;
|
|
const toDate = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
|
|
|
// Fetch entries
|
|
const entries = await fetchEntries(fromDate, toDate);
|
|
|
|
if (entries.length === 0) {
|
|
showNotification('Keine Einträge im Monat vorhanden', 'error');
|
|
return;
|
|
}
|
|
|
|
// Calculate statistics
|
|
const today = new Date();
|
|
today.setHours(23, 59, 59, 999); // Set to end of day to include today
|
|
|
|
// Find the last entry date to calculate workdays up to (parse correctly to avoid timezone issues)
|
|
let lastEntryDate = today;
|
|
if (entries.length > 0) {
|
|
const sortedEntries = entries.sort((a, b) => b.date.localeCompare(a.date));
|
|
const lastDateStr = sortedEntries[0].date; // "2025-10-22"
|
|
const [year, month, day] = lastDateStr.split('-').map(Number);
|
|
lastEntryDate = new Date(year, month - 1, day, 23, 59, 59, 999);
|
|
}
|
|
|
|
// Count workdays (excluding weekends and holidays, up to last entry or today, whichever is later)
|
|
const countUntil = lastEntryDate > today ? lastEntryDate : today;
|
|
let workdaysPassed = 0;
|
|
let totalWorkdaysInMonth = 0;
|
|
|
|
// Count vacation days to exclude from workdays
|
|
const vacationDaysSet = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'vacation')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
// Count flextime days (they are workdays with 0 hours worked)
|
|
const flextimeDaysSet = new Set(
|
|
entries
|
|
.filter(e => e.entryType === 'flextime')
|
|
.map(e => e.date)
|
|
);
|
|
|
|
for (let day = 1; day <= lastDay; day++) {
|
|
const dateObj = new Date(currentYear, currentMonth, day);
|
|
const yearStr = dateObj.getFullYear();
|
|
const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
|
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
|
|
|
|
const isVacation = vacationDaysSet.has(dateISO);
|
|
const isFlextime = flextimeDaysSet.has(dateISO);
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
|
|
if (!isWeekendHoliday && !isVacation) {
|
|
// Normal workday (excluding vacation days)
|
|
totalWorkdaysInMonth++;
|
|
if (dateObj <= countUntil) {
|
|
workdaysPassed++;
|
|
}
|
|
} else if (isFlextime && isWeekendHoliday) {
|
|
// Flextime on weekend/holiday counts as additional workday
|
|
totalWorkdaysInMonth++;
|
|
if (new Date(dateISO) <= countUntil) {
|
|
workdaysPassed++;
|
|
}
|
|
}
|
|
// Vacation days are excluded from all counts
|
|
}
|
|
|
|
let totalNetHours = 0;
|
|
let vacationDays = 0;
|
|
let flextimeDays = 0;
|
|
let workEntriesCount = 0;
|
|
|
|
// Create map of entries by date for proper handling
|
|
const entriesByDate = new Map();
|
|
entries.forEach(entry => {
|
|
if (!entriesByDate.has(entry.date)) {
|
|
entriesByDate.set(entry.date, []);
|
|
}
|
|
entriesByDate.get(entry.date).push(entry);
|
|
});
|
|
|
|
entries.forEach(entry => {
|
|
const entryDate = new Date(entry.date);
|
|
if (entryDate <= countUntil) {
|
|
if (!entry.entryType || entry.entryType === 'work') {
|
|
totalNetHours += entry.netHours;
|
|
workEntriesCount++;
|
|
} else if (entry.entryType === 'vacation') {
|
|
vacationDays++;
|
|
// Vacation hours are already included in netHours (8h per day typically)
|
|
totalNetHours += entry.netHours;
|
|
} else if (entry.entryType === 'flextime') {
|
|
flextimeDays++;
|
|
// Only add flextime hours if it's on a weekend/holiday
|
|
// (otherwise it's already counted as workday hours)
|
|
const dateObj = new Date(entry.date);
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
if (isWeekendHoliday) {
|
|
totalNetHours += entry.netHours;
|
|
}
|
|
// If flextime on regular workday, hours are already counted in work entries
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add running timer if active (only for current month)
|
|
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
|
|
let runningTimerHours = 0;
|
|
if (timerStartTime && isCurrentMonth) {
|
|
const now = Date.now();
|
|
const elapsed = now - timerStartTime;
|
|
const hours = elapsed / (1000 * 60 * 60);
|
|
const netHours = Math.max(0, hours - 0.5); // Subtract 30 min pause
|
|
runningTimerHours = netHours;
|
|
totalNetHours += netHours;
|
|
}
|
|
|
|
const targetHours = workdaysPassed * 8;
|
|
const monthBalance = totalNetHours - targetHours;
|
|
|
|
// Get previous balance
|
|
const previousBalance = await calculatePreviousBalance(year, month);
|
|
const totalBalance = monthBalance + previousBalance;
|
|
|
|
// Build table data
|
|
const allDaysData = [];
|
|
const entriesMap = new Map(entries.map(e => [e.date, e]));
|
|
|
|
for (let day = 1; day <= lastDay; day++) {
|
|
const dateObj = new Date(currentYear, currentMonth, day);
|
|
const yearStr = dateObj.getFullYear();
|
|
const monthStr = String(dateObj.getMonth() + 1).padStart(2, '0');
|
|
const dayStr = String(dateObj.getDate()).padStart(2, '0');
|
|
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
|
|
const formattedDate = formatDateDisplay(dateISO);
|
|
const weekday = dateObj.toLocaleDateString('de-DE', { weekday: 'short' });
|
|
|
|
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
|
|
const entry = entriesMap.get(dateISO);
|
|
|
|
if (entry) {
|
|
// Entry exists - use actual data
|
|
const deviation = entry.netHours - 8.0;
|
|
let deviationStr = Math.abs(deviation) < 0.01 ? '-' : (deviation >= 0 ? '+' : '') + deviation.toFixed(2) + 'h';
|
|
|
|
let locationText = '';
|
|
let startTime = entry.startTime;
|
|
let endTime = entry.endTime;
|
|
let pauseText = entry.pauseMinutes + ' min';
|
|
let netHoursText = entry.netHours.toFixed(2) + 'h';
|
|
|
|
if (entry.entryType === 'vacation') {
|
|
locationText = 'Urlaub';
|
|
startTime = '-';
|
|
endTime = '-';
|
|
pauseText = '-';
|
|
netHoursText = '-';
|
|
deviationStr = '-';
|
|
} else if (entry.entryType === 'flextime') {
|
|
locationText = 'Gleittag';
|
|
startTime = '-';
|
|
endTime = '-';
|
|
pauseText = '-';
|
|
} else {
|
|
locationText = entry.location === 'home' ? 'Home' : 'Office';
|
|
}
|
|
|
|
allDaysData.push([
|
|
formattedDate,
|
|
weekday,
|
|
startTime,
|
|
endTime,
|
|
pauseText,
|
|
locationText,
|
|
netHoursText,
|
|
deviationStr
|
|
]);
|
|
} else if (isWeekendHoliday) {
|
|
// Weekend or holiday without entry
|
|
const isWeekendDay = isWeekend(dateObj);
|
|
const isHoliday = isPublicHoliday(dateObj);
|
|
|
|
let dayType = '';
|
|
if (isWeekendDay) {
|
|
dayType = 'Wochenende';
|
|
} else if (isHoliday) {
|
|
dayType = 'Feiertag';
|
|
}
|
|
|
|
allDaysData.push([
|
|
formattedDate,
|
|
weekday,
|
|
'-',
|
|
'-',
|
|
'-',
|
|
dayType,
|
|
'-',
|
|
'-'
|
|
]);
|
|
}
|
|
// Skip regular workdays without entries
|
|
}
|
|
|
|
// Generate PDF using common template
|
|
const fileName = `Zeiterfassung_${monthName.replace(' ', '_')}.pdf`;
|
|
await generatePDF({
|
|
title: 'Zeiterfassung',
|
|
subtitle: monthName,
|
|
tableData: allDaysData,
|
|
statistics: { targetHours, totalNetHours, balance: monthBalance },
|
|
additionalInfo: { vacationDays, flextimeDays },
|
|
fileName
|
|
});
|
|
|
|
showNotification(`PDF für ${monthName} erstellt`, 'success');
|
|
} catch (error) {
|
|
console.error('Error exporting PDF:', error);
|
|
showNotification('Fehler beim PDF-Export', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export entire database
|
|
*/
|
|
async function exportDatabase() {
|
|
try {
|
|
// Fetch all data
|
|
const entries = await fetchEntries();
|
|
const settings = {
|
|
employeeName: await getSetting('employeeName') || '',
|
|
employeeId: await getSetting('employeeId') || '',
|
|
bundesland: await getSetting('bundesland') || 'NW',
|
|
vacationDaysPerYear: await getSetting('vacationDaysPerYear') || 30
|
|
};
|
|
|
|
// Create export object
|
|
const exportData = {
|
|
version: '1.0',
|
|
exportDate: new Date().toISOString(),
|
|
entries: entries,
|
|
settings: settings
|
|
};
|
|
|
|
// Convert to JSON
|
|
const jsonString = JSON.stringify(exportData, null, 2);
|
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
|
|
|
// Create download link
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `zeiterfassung_backup_${new Date().toISOString().split('T')[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showNotification(`Datenbank exportiert: ${entries.length} Einträge`, 'success');
|
|
} catch (error) {
|
|
console.error('Error exporting database:', error);
|
|
showNotification('Fehler beim Exportieren der Datenbank', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import entire database
|
|
*/
|
|
async function importDatabase(file) {
|
|
try {
|
|
const text = await file.text();
|
|
const importData = JSON.parse(text);
|
|
|
|
// Validate data structure
|
|
if (!importData.entries || !Array.isArray(importData.entries)) {
|
|
throw new Error('Ungültiges Datenbankformat');
|
|
}
|
|
|
|
// Confirm before overwriting
|
|
const confirmed = confirm(
|
|
`Möchten Sie die Datenbank wirklich importieren?\n\n` +
|
|
`Dies wird alle ${importData.entries.length} Einträge importieren und vorhandene Daten überschreiben.\n\n` +
|
|
`Export-Datum: ${new Date(importData.exportDate).toLocaleString('de-DE')}`
|
|
);
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
// Delete all existing entries
|
|
const existingEntries = await fetchEntries();
|
|
for (const entry of existingEntries) {
|
|
await fetch(`/api/entries/${entry.id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
// Import entries
|
|
let imported = 0;
|
|
for (const entry of importData.entries) {
|
|
try {
|
|
// Remove ID to create new entries
|
|
const { id, ...entryData } = entry;
|
|
await fetch('/api/entries', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(entryData)
|
|
});
|
|
imported++;
|
|
} catch (err) {
|
|
console.error('Error importing entry:', err);
|
|
}
|
|
}
|
|
|
|
// Import settings
|
|
if (importData.settings) {
|
|
await setSetting('employeeName', importData.settings.employeeName || '');
|
|
await setSetting('employeeId', importData.settings.employeeId || '');
|
|
await setSetting('bundesland', importData.settings.bundesland || 'NW');
|
|
await setSetting('vacationDaysPerYear', importData.settings.vacationDaysPerYear || 30);
|
|
await loadSettings(); // Reload settings to UI
|
|
}
|
|
|
|
// Reload view
|
|
await reloadView();
|
|
|
|
showNotification(`Datenbank importiert: ${imported} Einträge`, 'success');
|
|
} catch (error) {
|
|
console.error('Error importing database:', error);
|
|
showNotification('Fehler beim Importieren der Datenbank: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// FLATPICKR INITIALIZATION
|
|
// ============================================
|
|
|
|
/**
|
|
* Initialize all Flatpickr instances with optimized settings
|
|
*/
|
|
function initializeFlatpickr() {
|
|
// German locale
|
|
flatpickr.localize(flatpickr.l10ns.de);
|
|
|
|
// Date pickers - using DD.MM.YYYY format
|
|
const dateConfig = {
|
|
dateFormat: 'd.m.Y',
|
|
locale: 'de',
|
|
allowInput: true
|
|
};
|
|
|
|
filterFromPicker = flatpickr('#filterFrom', dateConfig);
|
|
filterToPicker = flatpickr('#filterTo', dateConfig);
|
|
datePicker = flatpickr('#modalDate', dateConfig);
|
|
|
|
// Time pickers with enhanced configuration
|
|
// For a better "tumbler" experience on desktop, we use:
|
|
// - enableTime: true
|
|
// - noCalendar: true (time only)
|
|
// - time_24hr: true (24-hour format)
|
|
// - minuteIncrement: 15 (15-minute steps)
|
|
// - Mobile browsers will show native time picker automatically
|
|
|
|
const timeConfig = {
|
|
enableTime: true,
|
|
noCalendar: true,
|
|
dateFormat: 'H:i',
|
|
time_24hr: true,
|
|
minuteIncrement: 15,
|
|
allowInput: true,
|
|
locale: 'de',
|
|
// Mobile devices will use native picker which has wheel/tumbler interface
|
|
// Desktop will use Flatpickr's time picker with improved UX
|
|
static: false,
|
|
// When opening the picker, use the current value in the input field
|
|
onOpen: function(selectedDates, dateStr, instance) {
|
|
const inputValue = instance.input.value;
|
|
if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) {
|
|
instance.setDate(inputValue, false);
|
|
}
|
|
}
|
|
};
|
|
|
|
startTimePicker = flatpickr('#modalStartTime', timeConfig);
|
|
endTimePicker = flatpickr('#modalEndTime', timeConfig);
|
|
|
|
// Initialize manual start time picker
|
|
manualStartTimePicker = flatpickr('#manualTimeInput', {
|
|
enableTime: true,
|
|
noCalendar: true,
|
|
dateFormat: 'H:i',
|
|
time_24hr: true,
|
|
minuteIncrement: 15,
|
|
allowInput: true,
|
|
locale: 'de',
|
|
defaultHour: 9,
|
|
defaultMinute: 0,
|
|
onOpen: function(selectedDates, dateStr, instance) {
|
|
const inputValue = instance.input.value;
|
|
if (inputValue && inputValue.match(/^\d{1,2}:\d{2}$/)) {
|
|
instance.setDate(inputValue, false);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle manual start time input
|
|
*/
|
|
async function handleManualStartTime(timeStr) {
|
|
const now = new Date();
|
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
|
|
// Parse the time
|
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hours, minutes, 0);
|
|
|
|
// Check if entry for today already exists
|
|
const entries = await fetchEntries(today, today);
|
|
|
|
if (entries.length > 0) {
|
|
showNotification('Für heute existiert bereits ein Eintrag', 'error');
|
|
return;
|
|
}
|
|
|
|
// Create entry with same start and end time (indicates running)
|
|
const entry = await createEntry(formatDateDisplay(today), timeStr, timeStr, null);
|
|
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
|
|
currentEntryId = entry.id;
|
|
timerStartTime = startDate.getTime();
|
|
timerStartTimeString = timeStr;
|
|
timerPausedDuration = 0;
|
|
isPaused = false;
|
|
|
|
// Update UI
|
|
const startBtn = document.getElementById('btnStartWork');
|
|
const stopBtn = document.getElementById('btnStopWork');
|
|
startBtn.disabled = true;
|
|
startBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
startBtn.classList.remove('hover:bg-green-700');
|
|
stopBtn.disabled = false;
|
|
stopBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
stopBtn.classList.add('hover:bg-red-700');
|
|
setTimerStatus('Läuft seit ' + timeStr, true);
|
|
|
|
// Calculate elapsed time and check for active pauses
|
|
const elapsed = now.getTime() - startDate.getTime();
|
|
const elapsedSeconds = Math.floor(elapsed / 1000);
|
|
|
|
// Check if we're in a pause
|
|
const sixHoursSeconds = 6 * 60 * 60;
|
|
const nineHoursSeconds = 9 * 60 * 60;
|
|
const thirtyMinutes = 30 * 60;
|
|
const fifteenMinutes = 15 * 60;
|
|
|
|
// Check if in 6-hour pause (6h to 6h30m real time)
|
|
if (elapsedSeconds >= sixHoursSeconds && elapsedSeconds < sixHoursSeconds + thirtyMinutes) {
|
|
isPaused = true;
|
|
pauseStartElapsed = sixHoursSeconds;
|
|
timerPausedDuration = 0;
|
|
const remainingPause = (sixHoursSeconds + thirtyMinutes) - elapsedSeconds;
|
|
pauseEndTime = Date.now() + (remainingPause * 1000);
|
|
setTimerStatus(`Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true);
|
|
|
|
// Schedule end of pause
|
|
pauseTimeout = setTimeout(() => {
|
|
timerPausedDuration = thirtyMinutes;
|
|
isPaused = false;
|
|
pauseStartElapsed = 0;
|
|
pauseEndTime = 0;
|
|
setTimerStatus('Läuft seit ' + timeStr, true);
|
|
}, remainingPause * 1000);
|
|
}
|
|
// Check if in 9-hour pause (9h30m to 9h45m real time = 9h to 9h work time)
|
|
else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
|
|
isPaused = true;
|
|
pauseStartElapsed = nineHoursSeconds; // Work time when pause starts
|
|
timerPausedDuration = thirtyMinutes;
|
|
const remainingPause = (nineHoursSeconds + thirtyMinutes + fifteenMinutes) - elapsedSeconds;
|
|
pauseEndTime = Date.now() + (remainingPause * 1000);
|
|
setTimerStatus(`Läuft seit ${timeStr} - Pause (${Math.ceil(remainingPause / 60)} Min)`, true);
|
|
|
|
// Schedule end of pause
|
|
pauseTimeout = setTimeout(() => {
|
|
timerPausedDuration = thirtyMinutes + fifteenMinutes;
|
|
isPaused = false;
|
|
pauseStartElapsed = 0;
|
|
pauseEndTime = 0;
|
|
setTimerStatus('Läuft seit ' + timeStr, true);
|
|
}, remainingPause * 1000);
|
|
}
|
|
// Not in pause, but may have completed pauses
|
|
else if (elapsedSeconds >= sixHoursSeconds + thirtyMinutes && elapsedSeconds < nineHoursSeconds + thirtyMinutes) {
|
|
// After first pause, before second pause
|
|
timerPausedDuration = thirtyMinutes;
|
|
} else if (elapsedSeconds >= nineHoursSeconds + thirtyMinutes + fifteenMinutes) {
|
|
// After both pauses
|
|
timerPausedDuration = thirtyMinutes + fifteenMinutes;
|
|
}
|
|
|
|
// Start timer interval
|
|
timerInterval = setInterval(updateTimer, 1000);
|
|
updateTimer(); // Immediate update
|
|
|
|
// Calculate offset for pause scheduling
|
|
schedulePausesWithOffset(elapsed);
|
|
|
|
// Reload view to show entry
|
|
await loadMonthlyView();
|
|
|
|
showNotification(`Timer gestartet ab ${timeStr}`, 'success');
|
|
}
|
|
|
|
// ============================================
|
|
// EVENT LISTENERS
|
|
// ============================================
|
|
|
|
function initializeEventListeners() {
|
|
// Auto-fill month button
|
|
document.getElementById('btnAutoFill').addEventListener('click', handleAutoFillMonth);
|
|
|
|
// PDF Export button
|
|
document.getElementById('btnExportPDF').addEventListener('click', handleExportPDF);
|
|
|
|
// Bulk edit toggle
|
|
document.getElementById('btnToggleBulkEdit').addEventListener('click', toggleBulkEditMode);
|
|
|
|
// Master checkbox
|
|
document.getElementById('masterCheckbox').addEventListener('change', handleMasterCheckboxToggle);
|
|
|
|
// Bulk edit actions
|
|
document.getElementById('btnSelectAll').addEventListener('click', selectAllEntries);
|
|
document.getElementById('btnDeselectAll').addEventListener('click', deselectAllEntries);
|
|
document.getElementById('btnBulkSetOffice').addEventListener('click', () => bulkSetLocation('office'));
|
|
document.getElementById('btnBulkSetHome').addEventListener('click', () => bulkSetLocation('home'));
|
|
document.getElementById('btnBulkSetVacation').addEventListener('click', bulkSetVacation);
|
|
document.getElementById('btnBulkSetFlextime').addEventListener('click', bulkSetFlextime);
|
|
document.getElementById('btnBulkDelete').addEventListener('click', bulkDeleteEntries);
|
|
document.getElementById('btnBulkExportPDF').addEventListener('click', bulkExportPDF);
|
|
|
|
// Cancel modal button
|
|
document.getElementById('btnCancelModal').addEventListener('click', closeModal);
|
|
|
|
// Location buttons
|
|
document.getElementById('btnLocationOffice').addEventListener('click', () => {
|
|
document.getElementById('modalLocation').value = 'office';
|
|
updateLocationButtons('office');
|
|
});
|
|
|
|
document.getElementById('btnLocationHome').addEventListener('click', () => {
|
|
document.getElementById('modalLocation').value = 'home';
|
|
updateLocationButtons('home');
|
|
});
|
|
|
|
// Form submission
|
|
document.getElementById('entryForm').addEventListener('submit', handleFormSubmit);
|
|
|
|
// Filter buttons
|
|
document.getElementById('btnFilter').addEventListener('click', handleFilter);
|
|
document.getElementById('btnClearFilter').addEventListener('click', handleClearFilter);
|
|
|
|
// Month navigation
|
|
document.getElementById('btnPrevMonth').addEventListener('click', handlePrevMonth);
|
|
document.getElementById('btnNextMonth').addEventListener('click', handleNextMonth);
|
|
|
|
// Export buttons
|
|
document.getElementById('btnExport').addEventListener('click', () => handleExport(false));
|
|
document.getElementById('btnExportDeviations').addEventListener('click', () => handleExport(true));
|
|
|
|
// Timer buttons
|
|
document.getElementById('btnStartWork').addEventListener('click', startWork);
|
|
document.getElementById('btnStopWork').addEventListener('click', stopWork);
|
|
|
|
// Target hours selector
|
|
document.getElementById('targetHoursSelect').addEventListener('change', (e) => {
|
|
targetHours = parseInt(e.target.value);
|
|
// Update metrics if timer is running
|
|
if (timerStartTime) {
|
|
const elapsed = Math.floor((Date.now() - timerStartTime) / 1000) - timerPausedDuration;
|
|
updateTimerMetrics(elapsed);
|
|
}
|
|
});
|
|
|
|
// Timer status - manual start time entry
|
|
document.getElementById('timerStatus').addEventListener('click', () => {
|
|
if (!timerStartTime) {
|
|
// Show custom time picker modal
|
|
document.getElementById('manualTimePickerModal').classList.remove('hidden');
|
|
// Set default time to 09:00
|
|
manualStartTimePicker.setDate('09:00', false);
|
|
}
|
|
});
|
|
|
|
// Manual time link - opens time picker when timer is not running
|
|
document.getElementById('manualTimeLink').addEventListener('click', () => {
|
|
// Show custom time picker modal
|
|
document.getElementById('manualTimePickerModal').classList.remove('hidden');
|
|
// Set default time to 09:00
|
|
manualStartTimePicker.setDate('09:00', false);
|
|
});
|
|
|
|
// Manual time picker - Confirm button
|
|
document.getElementById('btnConfirmManualTime').addEventListener('click', async () => {
|
|
const timeInput = document.getElementById('manualTimeInput');
|
|
const timeStr = timeInput.value; // Format: HH:MM from Flatpickr
|
|
|
|
if (timeStr && timeStr.match(/^\d{1,2}:\d{2}$/)) {
|
|
await handleManualStartTime(timeStr);
|
|
document.getElementById('manualTimePickerModal').classList.add('hidden');
|
|
} else {
|
|
showNotification('Bitte wählen Sie eine gültige Zeit aus', 'error');
|
|
}
|
|
});
|
|
|
|
// Manual time picker - Cancel button
|
|
document.getElementById('btnCancelManualTime').addEventListener('click', () => {
|
|
document.getElementById('manualTimePickerModal').classList.add('hidden');
|
|
});
|
|
|
|
// Close manual time picker when clicking outside
|
|
document.getElementById('manualTimePickerModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'manualTimePickerModal') {
|
|
document.getElementById('manualTimePickerModal').classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Bundesland selection
|
|
document.getElementById('bundeslandSelect').addEventListener('change', handleBundeslandChange);
|
|
|
|
// Vacation days input
|
|
document.getElementById('vacationDaysInput').addEventListener('change', handleVacationDaysChange);
|
|
|
|
// Company holiday preference
|
|
document.getElementById('companyHolidayChristmas').addEventListener('change', handleCompanyHolidayChange);
|
|
document.getElementById('companyHolidayNewYear').addEventListener('change', handleCompanyHolidayChange);
|
|
|
|
// Employee name input
|
|
document.getElementById('employeeName').addEventListener('change', async (e) => {
|
|
await setSetting('employeeName', e.target.value);
|
|
showNotification('Mitarbeitername gespeichert', 'success');
|
|
});
|
|
|
|
// Employee ID input
|
|
document.getElementById('employeeId').addEventListener('change', async (e) => {
|
|
await setSetting('employeeId', e.target.value);
|
|
showNotification('Personalnummer gespeichert', 'success');
|
|
});
|
|
|
|
// Database export/import
|
|
document.getElementById('btnExportDB').addEventListener('click', exportDatabase);
|
|
document.getElementById('btnImportDB').addEventListener('click', () => {
|
|
document.getElementById('importDBFile').click();
|
|
});
|
|
document.getElementById('importDBFile').addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
importDatabase(file);
|
|
e.target.value = ''; // Reset file input
|
|
}
|
|
});
|
|
|
|
// Close modal when clicking outside
|
|
document.getElementById('entryModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'entryModal') {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// Quick time buttons
|
|
document.querySelectorAll('.quick-time-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => handleQuickTimeButton(parseInt(btn.dataset.hours)));
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// INITIALIZATION
|
|
// ============================================
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
initializeFlatpickr();
|
|
initializeEventListeners();
|
|
await loadSettings(); // Load saved settings first
|
|
await loadVersionInfo(); // Load version info
|
|
checkRunningTimer(); // Check if timer was running
|
|
loadMonthlyView(); // Load monthly view by default
|
|
});
|