feat: add bridge days recommendations feature with display and calculation logic
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
This commit is contained in:
@@ -1282,6 +1282,7 @@ async function loadMonthlyView() {
|
||||
renderMonthlyView(entries);
|
||||
updateMonthDisplay();
|
||||
updateStatistics(entries);
|
||||
updateBridgeDaysDisplay();
|
||||
|
||||
// Show/hide PDF export button based on whether month is complete
|
||||
const today = new Date();
|
||||
@@ -1531,6 +1532,116 @@ async function updateVacationStatistics() {
|
||||
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)
|
||||
*/
|
||||
@@ -2696,7 +2807,65 @@ async function handleBundeslandChange(event) {
|
||||
const newBundesland = event.target.value;
|
||||
const oldBundesland = currentBundesland;
|
||||
|
||||
// Show warning with backup recommendation
|
||||
// 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',
|
||||
@@ -2716,17 +2885,23 @@ async function handleBundeslandChange(event) {
|
||||
'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: Bundesland ändern</h3>
|
||||
<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> Durch die Änderung der Feiertage können bestehende Einträge betroffen sein. An Tagen, die zu Feiertagen werden, bleiben Arbeitseinträge erhalten.</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">
|
||||
@@ -2780,59 +2955,6 @@ async function handleBundeslandChange(event) {
|
||||
* Perform the actual bundesland change after confirmation
|
||||
*/
|
||||
async function performBundeslandChange(newBundesland, oldBundesland, event) {
|
||||
// Check for conflicts with existing entries
|
||||
const entries = await fetchEntries();
|
||||
const conflicts = [];
|
||||
|
||||
// Get old and new holidays for comparison
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [currentYear - 1, currentYear, currentYear + 1];
|
||||
|
||||
const oldHolidays = new Set();
|
||||
const newHolidays = new Set();
|
||||
|
||||
years.forEach(year => {
|
||||
getPublicHolidays(year, oldBundesland).forEach(h => {
|
||||
oldHolidays.add(h.date.toISOString().split('T')[0]);
|
||||
});
|
||||
getPublicHolidays(year, newBundesland).forEach(h => {
|
||||
newHolidays.add(h.date.toISOString().split('T')[0]);
|
||||
});
|
||||
});
|
||||
|
||||
// Find dates that are holidays in new state but not in old state, and have entries
|
||||
entries.forEach(entry => {
|
||||
if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) {
|
||||
const dateObj = new Date(entry.date);
|
||||
// Temporarily set to new bundesland to get holiday name
|
||||
const tempBundesland = currentBundesland;
|
||||
currentBundesland = newBundesland;
|
||||
const holidayName = getHolidayName(dateObj);
|
||||
currentBundesland = tempBundesland;
|
||||
|
||||
conflicts.push({
|
||||
date: entry.date,
|
||||
displayDate: formatDateDisplay(entry.date),
|
||||
holidayName: holidayName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Show conflict info if exists
|
||||
if (conflicts.length > 0) {
|
||||
const conflictList = conflicts.map(c => ` • ${c.displayDate} (${c.holidayName})`).join('\n');
|
||||
const message = `Folgende Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nDie Einträge bleiben erhalten.`;
|
||||
showNotification(`⚠️ ${conflicts.length} Konflikt(e) gefunden`, 'warning');
|
||||
console.info(message);
|
||||
}
|
||||
|
||||
// Update state and save
|
||||
currentBundesland = newBundesland;
|
||||
await setSetting('bundesland', newBundesland);
|
||||
|
||||
// Reload view to show updated holidays
|
||||
await reloadView();
|
||||
|
||||
const bundeslandNames = {
|
||||
'BW': 'Baden-Württemberg',
|
||||
'BY': 'Bayern',
|
||||
@@ -2852,6 +2974,13 @@ async function performBundeslandChange(newBundesland, oldBundesland, event) {
|
||||
'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');
|
||||
}
|
||||
|
||||
@@ -2915,6 +3044,34 @@ async function handleVacationDaysChange(event) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user