feat: add company holiday preference feature with UI and logic for holiday selection
All checks were successful
Build and Push Docker Image / build (push) Successful in 29s

This commit is contained in:
Felix Schlusche
2025-10-30 16:14:03 +01:00
parent 4bdd9310ea
commit 282aaac8ae
4 changed files with 145 additions and 402 deletions

View File

@@ -856,6 +856,23 @@
</div>
</div>
<!-- Company Holiday Preference -->
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-full">
<label class="block text-sm font-medium text-gray-300 mb-2">Betriebsfrei am</label>
<div class="flex gap-2">
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
<input type="radio" name="companyHoliday" value="christmas" id="companyHolidayChristmas" class="mr-2" checked>
<span>Heiligabend (24.12.)</span>
</label>
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
<input type="radio" name="companyHoliday" value="newyearseve" id="companyHolidayNewYear" class="mr-2">
<span>Silvester (31.12.)</span>
</label>
</div>
</div>
</div>
<!-- Database Export/Import -->
<div class="mt-6 pt-4 border-t border-gray-600">
<h3 class="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">

View File

@@ -45,6 +45,15 @@ function getPublicHolidays(year, bundesland) {
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
// Company-provided holiday: Christmas Eve (24.12) or New Year's Eve (31.12)
// Default to Christmas if companyHolidayPreference is not defined
const companyHolidayPref = typeof companyHolidayPreference !== 'undefined' ? companyHolidayPreference : 'christmas';
if (companyHolidayPref === 'christmas') {
holidays.push({ date: new Date(year, 11, 24), name: 'Heiligabend (Betriebsfrei)' });
} else if (companyHolidayPref === 'newyearseve') {
holidays.push({ date: new Date(year, 11, 31), name: 'Silvester (Betriebsfrei)' });
}
// Heilige Drei Könige (BW, BY, ST)
if (['BW', 'BY', 'ST'].includes(bundesland)) {
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });

View File

@@ -21,109 +21,6 @@ let totalVacationDays = 30; // Default vacation days per year
// UTILITY FUNCTIONS
// ============================================
/**
* Format date from YYYY-MM-DD to DD.MM.YYYY
*/
function formatDateDisplay(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}.${month}.${year}`;
}
/**
* Format date from DD.MM.YYYY to YYYY-MM-DD
*/
function formatDateISO(dateStr) {
const [day, month, year] = dateStr.split('.');
return `${year}-${month}-${day}`;
}
/**
* Get today's date in YYYY-MM-DD format
*/
function getTodayISO() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Round time down to nearest 15 minutes
*/
function roundDownTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Round time up to nearest 15 minutes
*/
function roundUpTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.ceil(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Format time as HH:MM
*/
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Format seconds to HH:MM:SS
*/
function formatDuration(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/**
* Show toast notification
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer');
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Icon based on type
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span>${message}</span>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, 3000);
}
/**
* Get day of week abbreviation in German
*/
@@ -132,156 +29,6 @@ function getDayOfWeek(date) {
return days[date.getDay()];
}
/**
* Check if date is weekend
*/
function isWeekend(date) {
const day = date.getDay();
return day === 0 || day === 6; // Sunday or Saturday
}
/**
* Calculate Easter Sunday for a given year (Gauss algorithm)
*/
function getEasterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
/**
* Get all public holidays for a given year and Bundesland
*/
function getPublicHolidays(year, bundesland = currentBundesland) {
const holidays = [];
// Fixed holidays (all states)
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' });
holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' });
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
// Heilige Drei Könige (BW, BY, ST)
if (['BW', 'BY', 'ST'].includes(bundesland)) {
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
}
// Internationaler Frauentag (BE, MV since 2023)
if (['BE'].includes(bundesland) || (bundesland === 'MV' && year >= 2023)) {
holidays.push({ date: new Date(year, 2, 8), name: 'Internationaler Frauentag' });
}
// Weltkindertag (TH since 2019)
if (bundesland === 'TH' && year >= 2019) {
holidays.push({ date: new Date(year, 8, 20), name: 'Weltkindertag' });
}
// Reformationstag (BB, MV, SN, ST, TH, + HB, HH, NI, SH since 2018)
const reformationstagStates = ['BB', 'MV', 'SN', 'ST', 'TH'];
if (year >= 2018) {
reformationstagStates.push('HB', 'HH', 'NI', 'SH');
}
if (reformationstagStates.includes(bundesland)) {
holidays.push({ date: new Date(year, 9, 31), name: 'Reformationstag' });
}
// Allerheiligen (BW, BY, NW, RP, SL)
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' });
}
// Buß- und Bettag (only SN)
if (bundesland === 'SN') {
// Buß- und Bettag is the Wednesday before November 23
let bussbettag = new Date(year, 10, 23);
while (bussbettag.getDay() !== 3) { // 3 = Wednesday
bussbettag.setDate(bussbettag.getDate() - 1);
}
bussbettag.setDate(bussbettag.getDate() - 7); // One week before
holidays.push({ date: bussbettag, name: 'Buß- und Bettag' });
}
// Easter-dependent holidays
const easter = getEasterSunday(year);
// Karfreitag (Good Friday) - 2 days before Easter (all states)
const goodFriday = new Date(easter);
goodFriday.setDate(easter.getDate() - 2);
holidays.push({ date: goodFriday, name: 'Karfreitag' });
// Ostermontag (Easter Monday) - 1 day after Easter (all states)
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
holidays.push({ date: easterMonday, name: 'Ostermontag' });
// Christi Himmelfahrt (Ascension Day) - 39 days after Easter (all states)
const ascension = new Date(easter);
ascension.setDate(easter.getDate() + 39);
holidays.push({ date: ascension, name: 'Christi Himmelfahrt' });
// Pfingstmontag (Whit Monday) - 50 days after Easter (all states)
const whitMonday = new Date(easter);
whitMonday.setDate(easter.getDate() + 50);
holidays.push({ date: whitMonday, name: 'Pfingstmontag' });
// Fronleichnam (Corpus Christi) - 60 days after Easter (BW, BY, HE, NW, RP, SL, + some communities in SN, TH)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bundesland)) {
const corpusChristi = new Date(easter);
corpusChristi.setDate(easter.getDate() + 60);
holidays.push({ date: corpusChristi, name: 'Fronleichnam' });
}
// Mariä Himmelfahrt (Assumption of Mary) - August 15 (BY in some communities, SL)
if (['SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 7, 15), name: 'Mariä Himmelfahrt' });
}
return holidays;
}
/**
* Check if date is a public holiday
* Returns the holiday name or null
*/
function getHolidayName(date) {
const year = date.getFullYear();
const holidays = getPublicHolidays(year, currentBundesland);
const dateStr = date.toISOString().split('T')[0];
const holiday = holidays.find(h => {
return h.date.toISOString().split('T')[0] === dateStr;
});
return holiday ? holiday.name : null;
}
/**
* Check if date is a public holiday in Baden-Württemberg
*/
function isPublicHoliday(date) {
return getHolidayName(date) !== null;
}
/**
* Check if date is weekend or public holiday
*/
function isWeekendOrHoliday(date) {
return isWeekend(date) || isPublicHoliday(date);
}
/**
* Get month name in German
*/
@@ -641,134 +388,9 @@ function pauseTimer(durationSeconds) {
}, durationSeconds * 1000);
}
/**
* Fetch entries from the backend
*/
async function fetchEntries(fromDate = null, toDate = null) {
try {
let url = '/api/entries';
const params = new URLSearchParams();
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch entries');
}
const entries = await response.json();
return entries;
} catch (error) {
console.error('Error fetching entries:', error);
showNotification('Fehler beim Laden der Einträge', 'error');
return [];
}
}
/**
* Create a new entry
*/
async function createEntry(date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch('/api/entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error creating entry:', error);
showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error');
return null;
}
}
/**
* Update an existing entry
*/
async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch(`/api/entries/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error updating entry:', error);
showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error');
return null;
}
}
/**
* Delete an entry
*/
async function deleteEntry(id) {
try {
const response = await fetch(`/api/entries/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete entry');
}
return true;
} catch (error) {
console.error('Error deleting entry:', error);
showNotification('Fehler beim Löschen des Eintrags', 'error');
return false;
}
}
// ============================================
// SETTINGS API
// ============================================
/**
* Get a setting by key
@@ -1142,9 +764,11 @@ function renderMonthlyView(entries) {
</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>
@@ -1229,6 +853,15 @@ function renderMonthlyView(entries) {
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;
}
@@ -1854,6 +1487,16 @@ async function handleFormSubmit(e) {
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) {
@@ -3013,6 +2656,16 @@ async function loadSettings() {
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;
}
}
}
/**
@@ -3082,6 +2735,76 @@ async function handleVacationDaysChange(event) {
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
// ============================================
@@ -3295,6 +3018,12 @@ function handleFilter() {
currentFilterTo = toDate;
hideMonthNavigation();
// Hide bridge days recommendations in filter view
const bridgeDaysContainer = document.getElementById('bridgeDaysContainer');
if (bridgeDaysContainer) {
bridgeDaysContainer.classList.add('hidden');
}
loadEntries(fromDate, toDate);
}
@@ -3734,27 +3463,6 @@ async function importDatabase(file) {
}
}
/**
* Show a temporary notification
*/
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${
type === 'success' ? 'bg-green-600' :
type === 'error' ? 'bg-red-600' :
'bg-blue-600'
}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(() => document.body.removeChild(notification), 500);
}, 3000);
}
// ============================================
// FLATPICKR INITIALIZATION
// ============================================
@@ -4042,6 +3750,10 @@ function initializeEventListeners() {
// 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);

View File

@@ -22,6 +22,9 @@ let currentEntryId = null; // ID of today's entry being timed
let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth(); // 0-11
// Settings state
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
// Bulk edit state
let bulkEditMode = false;
let selectedEntries = new Set();
@@ -44,6 +47,8 @@ function setCurrentEntryId(id) { currentEntryId = id; }
function setDisplayYear(year) { displayYear = year; }
function setDisplayMonth(month) { displayMonth = month; }
function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
function setBulkEditMode(mode) { bulkEditMode = mode; }
function clearSelectedEntries() { selectedEntries.clear(); }
function addSelectedEntry(id) { selectedEntries.add(id); }