1010 lines
39 KiB
JavaScript
1010 lines
39 KiB
JavaScript
const state = {
|
|
settings: {},
|
|
entries: [],
|
|
corrections: {},
|
|
feriefridageOverrides: {},
|
|
flexOverrides: {},
|
|
plan: null,
|
|
};
|
|
|
|
const settingKeys = [
|
|
'previous_months', 'future_months',
|
|
'vacation_days_per_month', 'feriefridage_per_year', 'feriefridage_expiry_month',
|
|
'normal_day_hours', 'friday_hours'
|
|
];
|
|
|
|
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
const sourceLabels = {
|
|
auto: 'Smart',
|
|
vacation: 'Vacation',
|
|
feriefridag: 'Feriefridage',
|
|
flex: 'FLEX',
|
|
unpaid: 'Unpaid',
|
|
};
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
init().catch((error) => toast(error.message || String(error)));
|
|
|
|
async function init() {
|
|
bindEvents();
|
|
initHeaderEntriesToggle();
|
|
await loadState();
|
|
}
|
|
|
|
function bindEvents() {
|
|
$('addEntryBtn').addEventListener('click', () => openEntryDialog());
|
|
$('settingsBtn').addEventListener('click', openSettingsDialog);
|
|
$('entriesTable').querySelector('thead').addEventListener('click', toggleHeaderEntries);
|
|
$('closeEntryDialogBtn').addEventListener('click', closeEntryDialog);
|
|
$('closeSettingsDialogBtn').addEventListener('click', closeSettingsDialog);
|
|
$('entryDialog').addEventListener('click', closeDialogOnBackdrop);
|
|
$('settingsDialog').addEventListener('click', closeDialogOnBackdrop);
|
|
$('entryForm').addEventListener('submit', saveEntry);
|
|
$('settingsForm').addEventListener('submit', saveSettings);
|
|
$('deleteHiddenEntriesBtn').addEventListener('click', deleteEntriesOutsideDisplayedMonths);
|
|
['startDate', 'endDate', 'nonWorkingDays'].forEach((id) => $(id).addEventListener('input', updateWorkdayPreview));
|
|
$('cancelEditBtn').addEventListener('click', resetEntryForm);
|
|
}
|
|
|
|
async function loadState() {
|
|
const res = await fetch('api.php?action=state');
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Could not load state');
|
|
state.settings = normalizeSettings(data.settings);
|
|
state.entries = data.entries.map(normalizeEntry);
|
|
state.corrections = data.corrections || {};
|
|
state.feriefridageOverrides = data.feriefridage_overrides || {};
|
|
state.flexOverrides = data.flex_overrides || {};
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
}
|
|
|
|
function normalizeSettings(settings) {
|
|
const normalized = { ...settings };
|
|
normalized.previous_months = Number(normalized.previous_months ?? 5);
|
|
normalized.future_months = Number(normalized.future_months ?? 30);
|
|
return normalized;
|
|
}
|
|
|
|
function initHeaderEntriesToggle() {
|
|
const collapsed = localStorage.getItem('go.headerEntriesCollapsed') === '1';
|
|
setHeaderEntriesCollapsed(collapsed);
|
|
}
|
|
|
|
function toggleHeaderEntries() {
|
|
const entries = document.querySelector('.header-entries');
|
|
const collapsed = !entries?.classList.contains('is-collapsed');
|
|
setHeaderEntriesCollapsed(collapsed);
|
|
localStorage.setItem('go.headerEntriesCollapsed', collapsed ? '1' : '0');
|
|
}
|
|
|
|
function setHeaderEntriesCollapsed(collapsed) {
|
|
const entries = document.querySelector('.header-entries');
|
|
if (!entries) return;
|
|
entries.classList.toggle('is-collapsed', collapsed);
|
|
$('entriesTable').querySelector('thead').setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
|
}
|
|
|
|
function renderAll() {
|
|
fillSettingsForm();
|
|
renderDashboard();
|
|
renderHeaderWarnings();
|
|
renderWarnings();
|
|
renderEntries();
|
|
renderPlanner();
|
|
scheduleHaPublish();
|
|
}
|
|
|
|
function scheduleHaPublish() {
|
|
clearTimeout(scheduleHaPublish.timer);
|
|
scheduleHaPublish.timer = setTimeout(() => {
|
|
publishHaState().catch((error) => console.warn('HA publish failed', error));
|
|
}, 600);
|
|
}
|
|
|
|
async function publishHaState() {
|
|
if (!state.plan?.current) return;
|
|
const current = state.plan.current;
|
|
await post({
|
|
action: 'publishHa',
|
|
state: {
|
|
feriefridage: Number(current.feriefridage || 0),
|
|
vacation_days: Number(current.vacation || 0),
|
|
flex_hours: Number(current.flex || 0),
|
|
total_days: Number(current.totalDays || 0),
|
|
upcoming_vacations: upcomingVacationsForHa(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
});
|
|
}
|
|
|
|
function upcomingVacationsForHa() {
|
|
const today = toIsoDate(new Date());
|
|
return state.entries
|
|
.filter((entry) => entry.end_date >= today)
|
|
.sort((a, b) => a.start_date.localeCompare(b.start_date) || a.id - b.id)
|
|
.map((entry) => {
|
|
const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 };
|
|
return {
|
|
title: entry.title,
|
|
start: entry.start_date,
|
|
end: entry.end_date,
|
|
start_display: formatDate(entry.start_date),
|
|
end_display: formatDate(entry.end_date),
|
|
status: entry.status,
|
|
source: entry.source,
|
|
workdays: Number(summary.workdays || 0),
|
|
vacation_days: Number(summary.usedVacation || 0),
|
|
feriefridage: Number(summary.usedFeriefridage || 0),
|
|
flex_hours: Number(summary.usedFlex || 0),
|
|
flex_days: Number(summary.usedFlexDays || 0),
|
|
};
|
|
});
|
|
}
|
|
|
|
function fillSettingsForm() {
|
|
for (const key of settingKeys) {
|
|
const node = $(key);
|
|
if (node) node.value = state.settings[key] ?? '';
|
|
}
|
|
}
|
|
|
|
async function saveSettings(event) {
|
|
event.preventDefault();
|
|
const settings = {};
|
|
for (const key of settingKeys) {
|
|
settings[key] = Number($(key).value);
|
|
}
|
|
const data = await post({ action: 'saveSettings', settings });
|
|
state.settings = normalizeSettings(data.settings);
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
closeSettingsDialog();
|
|
toast('Settings saved');
|
|
}
|
|
|
|
async function saveBalanceOverride(event) {
|
|
const input = event.target;
|
|
const year = Number(input.dataset.year);
|
|
const month = Number(input.dataset.month);
|
|
const kind = input.dataset.kind;
|
|
const rawValue = String(input.value ?? '').trim();
|
|
const value = rawValue === '' ? null : parseDecimal(rawValue);
|
|
if (value !== null && Number.isNaN(value)) {
|
|
toast('Balance must be a number');
|
|
return;
|
|
}
|
|
|
|
const actions = {
|
|
vacation: 'saveCorrection',
|
|
feriefridage: 'saveFeriefridageOverride',
|
|
flex: 'saveFlexOverride',
|
|
};
|
|
const data = await post({ action: actions[kind], year, month, value });
|
|
if (kind === 'vacation') state.corrections = data.corrections || {};
|
|
if (kind === 'feriefridage') state.feriefridageOverrides = data.feriefridage_overrides || {};
|
|
if (kind === 'flex') state.flexOverrides = data.flex_overrides || {};
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
toast('Balance saved');
|
|
}
|
|
|
|
|
|
async function saveEntry(event) {
|
|
event.preventDefault();
|
|
const id = Number($('entryId').value || 0);
|
|
const entry = {
|
|
title: $('title').value.trim(),
|
|
start_date: $('startDate').value,
|
|
end_date: $('endDate').value,
|
|
source: $('source').value,
|
|
status: $('status').value,
|
|
allow_unpaid: $('allowUnpaid').checked ? 1 : 0,
|
|
non_working_days: Number($('nonWorkingDays').value || 0),
|
|
note: $('note').value.trim(),
|
|
};
|
|
|
|
const payload = id ? { action: 'updateEntry', id, entry } : { action: 'createEntry', entry };
|
|
const data = await post(payload);
|
|
state.entries = data.entries.map(normalizeEntry);
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
resetEntryForm();
|
|
closeEntryDialog();
|
|
renderAll();
|
|
toast(id ? 'Entry updated' : 'Entry added');
|
|
}
|
|
|
|
async function approveEntry(id) {
|
|
const entry = state.entries.find((item) => item.id === id);
|
|
if (!entry || entry.status !== 'planned') return;
|
|
const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } });
|
|
state.entries = data.entries.map(normalizeEntry);
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
toast('Entry approved');
|
|
}
|
|
|
|
async function deleteEntry(id) {
|
|
const entry = state.entries.find((item) => item.id === id);
|
|
if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return;
|
|
const data = await post({ action: 'deleteEntry', id });
|
|
state.entries = data.entries.map(normalizeEntry);
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
toast('Entry deleted');
|
|
}
|
|
|
|
async function deleteEntriesOutsideDisplayedMonths() {
|
|
const hiddenEntries = state.entries.filter((entry) => !entryOverlapsDisplayedRange(entry));
|
|
if (!hiddenEntries.length) {
|
|
toast('No hidden entries to delete');
|
|
return;
|
|
}
|
|
if (!confirm(`Delete ${hiddenEntries.length} entries outside the currently displayed months?`)) return;
|
|
|
|
let latestEntries = state.entries;
|
|
for (const entry of hiddenEntries) {
|
|
const data = await post({ action: 'deleteEntry', id: entry.id });
|
|
latestEntries = data.entries.map(normalizeEntry);
|
|
}
|
|
state.entries = latestEntries;
|
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
|
renderAll();
|
|
toast('Hidden entries deleted');
|
|
}
|
|
|
|
function entriesInDisplayedMonths() {
|
|
return state.entries.filter(entryOverlapsDisplayedRange);
|
|
}
|
|
|
|
function entryOverlapsDisplayedRange(entry) {
|
|
if (!state.plan?.range) return true;
|
|
return entry.start_date <= state.plan.range.end && entry.end_date >= state.plan.range.start;
|
|
}
|
|
|
|
function isPastEntry(entry) {
|
|
return entry.end_date < toIsoDate(new Date());
|
|
}
|
|
|
|
function isPastMonth(month) {
|
|
const today = new Date();
|
|
return month.year < today.getFullYear() || (month.year === today.getFullYear() && month.month < today.getMonth() + 1);
|
|
}
|
|
|
|
async function post(payload) {
|
|
const res = await fetch('api.php', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Request failed');
|
|
return data;
|
|
}
|
|
|
|
function openEntryDialog(entryId = null) {
|
|
if (!entryId) resetEntryForm();
|
|
$('entryDialog').showModal();
|
|
}
|
|
|
|
function closeEntryDialog() {
|
|
$('entryDialog').close();
|
|
}
|
|
|
|
function openSettingsDialog() {
|
|
fillSettingsForm();
|
|
$('settingsDialog').showModal();
|
|
}
|
|
|
|
function closeSettingsDialog() {
|
|
$('settingsDialog').close();
|
|
}
|
|
|
|
function closeDialogOnBackdrop(event) {
|
|
if (event.target === event.currentTarget) event.currentTarget.close();
|
|
}
|
|
|
|
function editEntry(id) {
|
|
const entry = state.entries.find((item) => item.id === id);
|
|
if (!entry) return;
|
|
$('entryId').value = entry.id;
|
|
$('title').value = entry.title;
|
|
$('startDate').value = entry.start_date;
|
|
$('endDate').value = entry.end_date;
|
|
$('source').value = entry.source;
|
|
$('status').value = entry.status;
|
|
$('allowUnpaid').checked = Boolean(Number(entry.allow_unpaid));
|
|
$('nonWorkingDays').value = Number(entry.non_working_days || 0);
|
|
$('note').value = entry.note || '';
|
|
updateWorkdayPreview();
|
|
$('entryFormTitle').textContent = 'Edit date-range entry';
|
|
$('saveEntryBtn').textContent = 'Update entry';
|
|
$('cancelEditBtn').classList.remove('hidden');
|
|
openEntryDialog(id);
|
|
}
|
|
|
|
function updateWorkdayPreview() {
|
|
const start = $('startDate').value;
|
|
const end = $('endDate').value;
|
|
const extra = Number($('nonWorkingDays').value || 0);
|
|
const preview = $('workdayPreview');
|
|
if (!start || !end) {
|
|
preview.textContent = 'Set dates to see calculated working days.';
|
|
return;
|
|
}
|
|
if (start > end) {
|
|
preview.textContent = 'Start date must be before end date.';
|
|
return;
|
|
}
|
|
const gross = workdaysBetween(start, end).length;
|
|
const effective = Math.max(0, gross - extra);
|
|
preview.textContent = `${days(effective)} working days will be used (${days(gross)} weekdays minus ${days(extra)} additional non-working days).`;
|
|
}
|
|
|
|
function resetEntryForm() {
|
|
$('entryForm').reset();
|
|
$('entryId').value = '';
|
|
$('source').value = 'auto';
|
|
$('status').value = 'planned';
|
|
$('nonWorkingDays').value = 0;
|
|
updateWorkdayPreview();
|
|
$('entryFormTitle').textContent = 'Add date-range entry';
|
|
$('saveEntryBtn').textContent = 'Save entry';
|
|
$('cancelEditBtn').classList.add('hidden');
|
|
}
|
|
|
|
function renderDashboard() {
|
|
const current = state.plan.current;
|
|
const cards = [
|
|
['Feriefridage', days(current.feriefridage), 'Use before 31 August', current.feriefridage > 0.001 ? 'warn' : ''],
|
|
['Vacation days', days(current.vacation), 'Available today', current.vacation < -0.001 ? 'bad' : 'good'],
|
|
];
|
|
|
|
$('dashboard').innerHTML = `
|
|
${cards.map(([label, value, hint, tone]) => `
|
|
<article class="card ${tone}">
|
|
<div class="label">${escapeHtml(label)}</div>
|
|
<div class="value">${value}</div>
|
|
<div class="hint">${escapeHtml(hint)}</div>
|
|
</article>
|
|
`).join('')}
|
|
<article class="card ${current.flex < -0.001 ? 'bad' : 'good'}">
|
|
<div class="label">FLEX hours</div>
|
|
<div class="value">${hours(current.flex)}</div>
|
|
<div class="hint">Current balance</div>
|
|
</article>
|
|
<article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}">
|
|
<div class="label">Total days</div>
|
|
<div class="value">${days(current.totalDays)}</div>
|
|
<div class="hint">Vacation + FF + FLEX</div>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderHeaderWarnings() {
|
|
const warnings = state.plan.current.warnings;
|
|
$('headerWarnings').innerHTML = warnings.length
|
|
? warnings.map((warning) => `<span class="header-warning ${warning.level === 'bad' ? 'bad' : ''}">${escapeHtml(warning.text)}</span>`).join('')
|
|
: '';
|
|
}
|
|
|
|
function renderWarnings() {
|
|
const warningsNode = $('warnings');
|
|
if (!warningsNode) return;
|
|
const warnings = state.plan.warnings;
|
|
if (!warnings.length) {
|
|
warningsNode.innerHTML = '<div class="warning good">No warnings. Your current plan looks safe.</div>';
|
|
return;
|
|
}
|
|
warningsNode.innerHTML = warnings.map((warning) => `
|
|
<div class="warning ${warning.level === 'bad' ? 'bad' : ''}">${escapeHtml(warning.text)}</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderEntries() {
|
|
const visibleEntries = entriesInDisplayedMonths();
|
|
$('entriesHeaderTitle').textContent = `VACATIONS (${visibleEntries.length})`;
|
|
const tbody = $('entriesTable').querySelector('tbody');
|
|
if (!visibleEntries.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="muted">No entries in displayed months.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = visibleEntries.map((entry) => {
|
|
const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, grossWorkdays: 0, nonWorkingDays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 };
|
|
const source = sourceLabels[entry.source] || entry.source;
|
|
return `
|
|
<tr class="${isPastEntry(entry) ? 'past-entry' : ''}">
|
|
<td><strong>${escapeHtml(entry.title)}</strong> <span class="muted">${escapeHtml(source)}</span><br><span class="muted entry-date">${formatDate(entry.start_date)} → ${formatDate(entry.end_date)}</span>${Number(entry.allow_unpaid) ? ' <span class="pill unpaid">unpaid</span>' : ''}</td>
|
|
<td class="num strong-num">${days(summary.workdays)}</td>
|
|
<td class="num">${days(summary.usedVacation)}</td>
|
|
<td class="num">${days(summary.usedFeriefridage)}</td>
|
|
<td class="num"><span title="${days(summary.usedFlexDays)} FLEX days">${hours(summary.usedFlex)}</span><br><span class="muted">${days(summary.usedFlexDays)}d</span></td>
|
|
<td><span class="pill ${entry.status === 'approved' ? 'approved' : ''}">${escapeHtml(entry.status)}</span></td>
|
|
<td>
|
|
<div class="entry-actions">
|
|
${entry.status === 'planned' ? `<button class="small" onclick="approveEntry(${entry.id})">Approve</button>` : ''}
|
|
<button class="ghost small" onclick="editEntry(${entry.id})">Edit</button>
|
|
<button class="danger small" onclick="deleteEntry(${entry.id})">Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderPlanner() {
|
|
$('planner').innerHTML = `
|
|
<div class="table-wrap">
|
|
<table class="month-table planner-body-table">
|
|
<tbody>
|
|
${state.plan.months.map(renderMonthRow).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
document.querySelectorAll('.balance-override-input').forEach((input) => {
|
|
input.addEventListener('change', (event) => saveBalanceOverride(event).catch((error) => toast(error.message || String(error))));
|
|
});
|
|
syncPlannerScroll();
|
|
}
|
|
|
|
function syncPlannerScroll() {
|
|
const headerWrap = document.querySelector('.planner-column-header.table-wrap');
|
|
const bodyWrap = document.querySelector('.planner-panel .table-wrap');
|
|
if (!headerWrap || !bodyWrap) return;
|
|
|
|
let syncing = false;
|
|
const sync = (from, to) => {
|
|
if (syncing) return;
|
|
syncing = true;
|
|
to.scrollLeft = from.scrollLeft;
|
|
requestAnimationFrame(() => { syncing = false; });
|
|
};
|
|
|
|
headerWrap.addEventListener('scroll', () => sync(headerWrap, bodyWrap), { passive: true });
|
|
bodyWrap.addEventListener('scroll', () => sync(bodyWrap, headerWrap), { passive: true });
|
|
headerWrap.scrollLeft = bodyWrap.scrollLeft;
|
|
}
|
|
|
|
function renderMonthRow(month) {
|
|
const balanceClass = month.availableDays < -0.001 ? 'bad-text' : 'good-text';
|
|
const entries = month.entries.length
|
|
? month.entries.map((item) => `
|
|
<span class="month-entry ${isPastEntry(item) ? 'past-vacation-entry' : ''}">
|
|
<strong>${escapeHtml(item.title)}</strong>
|
|
<small class="planner-entry-meta"><strong>${days(item.workdays)} wd</strong> · ${days(item.usedVacation)} vd · ${days(item.usedFeriefridage)} ff · ${hours(item.usedFlex)} fl</small>
|
|
<small class="planner-entry-dates">${formatDate(item.start_date)} → ${formatDate(item.end_date)}</small>
|
|
</span>
|
|
`).join('')
|
|
: '<span class="muted">—</span>';
|
|
|
|
return `
|
|
<tr class="${isPastMonth(month) ? 'past-month' : ''}">
|
|
<td>${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `<br><span class="warn-text">Expired FF: ${days(month.expiredFeriefridage)}</span>` : ''}</td>
|
|
<td class="entries">${entries}</td>
|
|
<td class="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input ${month.hasVacationOverride ? 'is-overridden' : ''}" type="number" step="0.01" value="${month.hasVacationOverride ? Number(month.vacationOverride) : Number(month.vacationBalance.toFixed(2))}" data-kind="vacation" data-year="${month.year}" data-month="${month.month}" aria-label="Vacation days for ${monthNames[month.month - 1]} ${month.year}"></td>
|
|
<td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input ${month.hasFeriefridageOverride ? 'is-overridden' : ''}" type="number" step="0.01" value="${month.hasFeriefridageOverride ? Number(month.feriefridageOverride) : Number(month.feriefridageBalance.toFixed(2))}" data-kind="feriefridage" data-year="${month.year}" data-month="${month.month}" aria-label="Feriefridage for ${monthNames[month.month - 1]} ${month.year}"></td>
|
|
<td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input ${month.hasFlexOverride ? 'is-overridden' : ''}" type="number" step="0.25" value="${month.hasFlexOverride ? Number(month.flexOverride) : Number(month.flexBalance.toFixed(2))}" data-kind="flex" data-year="${month.year}" data-month="${month.month}" aria-label="FLEX hours for ${monthNames[month.month - 1]} ${month.year}"></td>
|
|
<td class="num important-col ${balanceClass}">${days(month.availableDays)}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function calculatePlan(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) {
|
|
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
|
const previousMonths = Number(settings.previous_months ?? 5);
|
|
const futureMonths = Number(settings.future_months ?? 30);
|
|
const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1);
|
|
const startYear = startCursor.getFullYear();
|
|
const startMonth = startCursor.getMonth() + 1;
|
|
const monthCount = previousMonths + futureMonths + 1;
|
|
const startDate = dateFromParts(startYear, startMonth, 1);
|
|
const endDate = dateFromParts(startYear, startMonth + monthCount, 0);
|
|
const months = [];
|
|
const warnings = [];
|
|
const entrySummaries = {};
|
|
const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
|
|
|
|
let vacationBalance = Number(settings.opening_vacation_days || 0);
|
|
let feriefridageBalance = Number(settings.opening_feriefridage_days || 0);
|
|
let flexBalance = Number(settings.opening_flex_hours || 0);
|
|
|
|
const allWorkdays = [];
|
|
for (const entry of entries) {
|
|
const grossDates = workdaysBetween(entry.start_date, entry.end_date);
|
|
const units = workdayUnitsForEntry(entry);
|
|
entrySummaries[entry.id] = { workdays: sumFractions(units), grossWorkdays: grossDates.length, nonWorkingDays: Number(entry.non_working_days || 0), usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 };
|
|
for (const unit of units) {
|
|
if (unit.date < startDate || unit.date > endDate) continue;
|
|
allWorkdays.push({ date: unit.date, fraction: unit.fraction, entryId: entry.id });
|
|
}
|
|
}
|
|
allWorkdays.sort((a, b) => a.date - b.date || a.entryId - b.entryId);
|
|
|
|
const workdaysByMonth = new Map();
|
|
for (const item of allWorkdays) {
|
|
const key = monthKey(item.date.getFullYear(), item.date.getMonth() + 1);
|
|
if (!workdaysByMonth.has(key)) workdaysByMonth.set(key, []);
|
|
workdaysByMonth.get(key).push(item);
|
|
}
|
|
|
|
const avgDayHours = averageDayHours(settings);
|
|
const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance);
|
|
|
|
for (let i = 0; i < monthCount; i++) {
|
|
const cursor = dateFromParts(startYear, startMonth + i, 1);
|
|
const year = cursor.getFullYear();
|
|
const month = cursor.getMonth() + 1;
|
|
const current = {
|
|
year,
|
|
month,
|
|
earnedVacation: Number(settings.vacation_days_per_month || 0),
|
|
hasVacationOverride: hasCorrection(corrections, year, month),
|
|
vacationOverride: correctionFor(corrections, year, month),
|
|
hasFeriefridageOverride: hasCorrection(feriefridageOverrides, year, month),
|
|
feriefridageOverride: correctionFor(feriefridageOverrides, year, month),
|
|
hasFlexOverride: hasCorrection(flexOverrides, year, month),
|
|
flexOverride: correctionFor(flexOverrides, year, month),
|
|
grantedFeriefridage: month === 1 ? Number(settings.feriefridage_per_year || 0) : 0,
|
|
expiredFeriefridage: 0,
|
|
usedVacation: 0,
|
|
usedFeriefridage: 0,
|
|
usedFlex: 0,
|
|
usedUnpaid: 0,
|
|
vacationBalance: 0,
|
|
feriefridageBalance: 0,
|
|
flexBalance: 0,
|
|
availableDays: 0,
|
|
entries: [],
|
|
};
|
|
|
|
feriefridageBalance += current.grantedFeriefridage;
|
|
|
|
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
|
current.expiredFeriefridage = feriefridageBalance;
|
|
if (year === today.getFullYear()) {
|
|
warnings.push({ level: 'bad', text: `${days(feriefridageBalance)} feriefridage expired on 31 August ${year}. They must be used before that date.` });
|
|
}
|
|
feriefridageBalance = 0;
|
|
}
|
|
|
|
const key = monthKey(year, month);
|
|
const monthItems = workdaysByMonth.get(key) || [];
|
|
const isThisMonth = year === today.getFullYear() && month === today.getMonth() + 1;
|
|
const applyWorkday = (item) => {
|
|
const entry = entriesById.get(item.entryId);
|
|
const allocation = allocateDay(entry, item.date, {
|
|
vacationBalance,
|
|
feriefridageBalance,
|
|
flexBalance,
|
|
}, settings, item.fraction);
|
|
|
|
vacationBalance = allocation.balances.vacationBalance;
|
|
feriefridageBalance = allocation.balances.feriefridageBalance;
|
|
flexBalance = allocation.balances.flexBalance;
|
|
|
|
current.usedVacation += allocation.usedVacation;
|
|
current.usedFeriefridage += allocation.usedFeriefridage;
|
|
current.usedFlex += allocation.usedFlex;
|
|
current.usedUnpaid += allocation.usedUnpaid;
|
|
|
|
const summary = entrySummaries[entry.id];
|
|
summary.usedVacation += allocation.usedVacation;
|
|
summary.usedFeriefridage += allocation.usedFeriefridage;
|
|
summary.usedFlex += allocation.usedFlex;
|
|
summary.usedFlexDays += allocation.usedFlexDays;
|
|
summary.usedUnpaid += allocation.usedUnpaid;
|
|
|
|
let monthEntry = current.entries.find((row) => row.id === entry.id);
|
|
if (!monthEntry) {
|
|
monthEntry = {
|
|
id: entry.id,
|
|
title: entry.title,
|
|
source: sourceLabels[entry.source] || entry.source,
|
|
start_date: entry.start_date,
|
|
end_date: entry.end_date,
|
|
workdays: 0,
|
|
usedVacation: 0,
|
|
usedFeriefridage: 0,
|
|
usedFlex: 0,
|
|
usedFlexDays: 0,
|
|
};
|
|
current.entries.push(monthEntry);
|
|
}
|
|
monthEntry.workdays += item.fraction;
|
|
monthEntry.usedVacation += allocation.usedVacation;
|
|
monthEntry.usedFeriefridage += allocation.usedFeriefridage;
|
|
monthEntry.usedFlex += allocation.usedFlex;
|
|
monthEntry.usedFlexDays += allocation.usedFlexDays;
|
|
};
|
|
|
|
if (isThisMonth) {
|
|
monthItems.filter((item) => item.date <= today).forEach(applyWorkday);
|
|
flexBalance = currentFlexHours;
|
|
monthItems.filter((item) => item.date > today).forEach(applyWorkday);
|
|
} else {
|
|
monthItems.forEach(applyWorkday);
|
|
}
|
|
|
|
vacationBalance += current.earnedVacation;
|
|
|
|
if (month === Number(settings.feriefridage_expiry_month || 8) && year === today.getFullYear() && feriefridageBalance > 0.001) {
|
|
warnings.push({ level: 'warn', text: `${days(feriefridageBalance)} feriefridage remain at the end of August ${year}; they must be used before 31 August.` });
|
|
}
|
|
|
|
if (current.hasVacationOverride) vacationBalance = current.vacationOverride;
|
|
if (current.hasFeriefridageOverride) feriefridageBalance = current.feriefridageOverride;
|
|
if (current.hasFlexOverride) flexBalance = current.flexOverride;
|
|
|
|
current.vacationBalance = vacationBalance;
|
|
current.feriefridageBalance = feriefridageBalance;
|
|
current.flexBalance = flexBalance;
|
|
current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
|
|
|
months.push(current);
|
|
|
|
if (vacationBalance < -0.001) {
|
|
warnings.push({ level: 'bad', text: `Vacation balance is negative in ${monthNames[month - 1]} ${year}: ${days(vacationBalance)} days.` });
|
|
}
|
|
if (feriefridageBalance < -0.001) {
|
|
warnings.push({ level: 'bad', text: `Feriefridage balance is negative in ${monthNames[month - 1]} ${year}: ${days(feriefridageBalance)} days.` });
|
|
}
|
|
if (flexBalance < -0.001) {
|
|
warnings.push({ level: 'bad', text: `FLEX balance is negative in ${monthNames[month - 1]} ${year}: ${hours(flexBalance)}.` });
|
|
}
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const summary = entrySummaries[entry.id];
|
|
if (!summary) continue;
|
|
if (summary.usedUnpaid > 0.001) {
|
|
warnings.push({ level: 'warn', text: `${entry.title}: ${days(summary.usedUnpaid)} unpaid days are used because unpaid fallback is enabled for that date range.` });
|
|
}
|
|
}
|
|
|
|
const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
|
return {
|
|
months,
|
|
range: { start: toIsoDate(startDate), end: toIsoDate(endDate) },
|
|
warnings: dedupeWarnings(warnings),
|
|
current: calculateCurrentSnapshot(settings, entries, corrections, feriefridageOverrides, flexOverrides),
|
|
entrySummaries,
|
|
final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays },
|
|
};
|
|
}
|
|
|
|
function calculateCurrentSnapshot(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) {
|
|
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
|
const previousMonths = Number(settings.previous_months ?? 5);
|
|
const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1);
|
|
const startYear = startCursor.getFullYear();
|
|
const startMonth = startCursor.getMonth() + 1;
|
|
const startDate = dateFromParts(startYear, startMonth, 1);
|
|
const avgDayHours = averageDayHours(settings);
|
|
const warnings = [];
|
|
|
|
let vacationBalance = Number(settings.opening_vacation_days || 0);
|
|
let feriefridageBalance = Number(settings.opening_feriefridage_days || 0);
|
|
let flexBalance = Number(settings.opening_flex_hours || 0);
|
|
const currentFlexHours = Number(settings.current_flex_hours ?? flexBalance);
|
|
|
|
if (today < startDate) {
|
|
flexBalance = currentFlexHours;
|
|
const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
|
return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings };
|
|
}
|
|
|
|
const workdays = [];
|
|
for (const entry of entries) {
|
|
for (const unit of workdayUnitsForEntry(entry)) {
|
|
if (unit.date >= startDate && unit.date <= today) workdays.push({ date: unit.date, fraction: unit.fraction, entry });
|
|
}
|
|
}
|
|
workdays.sort((a, b) => a.date - b.date || a.entry.id - b.entry.id);
|
|
|
|
let workdayIndex = 0;
|
|
const monthCount = ((today.getFullYear() - startYear) * 12) + (today.getMonth() + 1 - startMonth) + 1;
|
|
for (let i = 0; i < monthCount; i++) {
|
|
const cursor = dateFromParts(startYear, startMonth + i, 1);
|
|
const year = cursor.getFullYear();
|
|
const month = cursor.getMonth() + 1;
|
|
|
|
if (month === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0);
|
|
|
|
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
|
feriefridageBalance = 0;
|
|
}
|
|
|
|
const monthStart = dateFromParts(year, month, 1);
|
|
const monthEnd = dateFromParts(year, month + 1, 0);
|
|
const limit = today < monthEnd ? today : monthEnd;
|
|
while (workdayIndex < workdays.length && workdays[workdayIndex].date >= monthStart && workdays[workdayIndex].date <= limit) {
|
|
const item = workdays[workdayIndex++];
|
|
const allocation = allocateDay(item.entry, item.date, { vacationBalance, feriefridageBalance, flexBalance }, settings, item.fraction);
|
|
vacationBalance = allocation.balances.vacationBalance;
|
|
feriefridageBalance = allocation.balances.feriefridageBalance;
|
|
flexBalance = allocation.balances.flexBalance;
|
|
}
|
|
|
|
if (today >= monthEnd) {
|
|
vacationBalance += Number(settings.vacation_days_per_month || 0);
|
|
}
|
|
|
|
if (year === today.getFullYear() && month === today.getMonth() + 1) {
|
|
flexBalance = currentFlexHours;
|
|
}
|
|
|
|
if (hasCorrection(corrections, year, month)) vacationBalance = correctionFor(corrections, year, month);
|
|
if (hasCorrection(feriefridageOverrides, year, month)) feriefridageBalance = correctionFor(feriefridageOverrides, year, month);
|
|
if (hasCorrection(flexOverrides, year, month)) flexBalance = correctionFor(flexOverrides, year, month);
|
|
}
|
|
|
|
if (!hasCorrection(flexOverrides, today.getFullYear(), today.getMonth() + 1)) {
|
|
flexBalance = currentFlexHours;
|
|
}
|
|
|
|
if (vacationBalance < -0.001) warnings.push({ level: 'bad', text: `Vacation balance is negative today: ${days(vacationBalance)} days.` });
|
|
if (feriefridageBalance < -0.001) warnings.push({ level: 'bad', text: `Feriefridage balance is negative today: ${days(feriefridageBalance)} days.` });
|
|
if (flexBalance < -0.001) warnings.push({ level: 'bad', text: `FLEX balance is negative today: ${hours(flexBalance)}.` });
|
|
if (today.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8) && feriefridageBalance > 0.001) {
|
|
const remainingAfterApprovedPlans = feriefridageRemainingAfterApprovedPlans(settings, entries, today, {
|
|
vacationBalance,
|
|
feriefridageBalance,
|
|
flexBalance,
|
|
});
|
|
if (remainingAfterApprovedPlans > 0.001) {
|
|
warnings.push({ level: 'warn', text: `${days(remainingAfterApprovedPlans)} feriefridage need approved plan before 31 August.` });
|
|
}
|
|
}
|
|
|
|
const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
|
return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings: dedupeWarnings(warnings) };
|
|
}
|
|
|
|
function feriefridageRemainingAfterApprovedPlans(settings, entries, today, balances) {
|
|
const expiryMonth = Number(settings.feriefridage_expiry_month || 8);
|
|
const deadline = dateFromParts(today.getFullYear(), expiryMonth + 1, 0);
|
|
let { vacationBalance, feriefridageBalance, flexBalance } = balances;
|
|
|
|
const futureApprovedWorkdays = [];
|
|
for (const entry of entries) {
|
|
if (entry.status !== 'approved') continue;
|
|
for (const unit of workdayUnitsForEntry(entry)) {
|
|
if (unit.date > today && unit.date <= deadline) {
|
|
futureApprovedWorkdays.push({ date: unit.date, fraction: unit.fraction, entry });
|
|
}
|
|
}
|
|
}
|
|
|
|
futureApprovedWorkdays.sort((a, b) => a.date - b.date || a.entry.id - b.entry.id);
|
|
for (const item of futureApprovedWorkdays) {
|
|
const allocation = allocateDay(item.entry, item.date, { vacationBalance, feriefridageBalance, flexBalance }, settings, item.fraction);
|
|
vacationBalance = allocation.balances.vacationBalance;
|
|
feriefridageBalance = allocation.balances.feriefridageBalance;
|
|
flexBalance = allocation.balances.flexBalance;
|
|
}
|
|
|
|
return feriefridageBalance;
|
|
}
|
|
|
|
function allocateDay(entry, date, balances, settings, fraction = 1) {
|
|
const source = entry.source;
|
|
const allowUnpaid = Boolean(Number(entry.allow_unpaid));
|
|
const baseDayHours = hoursForDate(date, settings);
|
|
const dayHours = baseDayHours * fraction;
|
|
let { vacationBalance, feriefridageBalance, flexBalance } = balances;
|
|
const result = { usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 };
|
|
|
|
const useVacation = () => {
|
|
if (allowUnpaid && vacationBalance < fraction) {
|
|
const use = Math.max(0, vacationBalance);
|
|
result.usedVacation += use;
|
|
result.usedUnpaid += fraction - use;
|
|
vacationBalance = 0;
|
|
} else {
|
|
result.usedVacation += fraction;
|
|
vacationBalance -= fraction;
|
|
}
|
|
};
|
|
|
|
const useFeriefridag = () => {
|
|
if (allowUnpaid && feriefridageBalance < fraction) {
|
|
const use = Math.max(0, feriefridageBalance);
|
|
result.usedFeriefridage += use;
|
|
result.usedUnpaid += fraction - use;
|
|
feriefridageBalance = 0;
|
|
} else {
|
|
result.usedFeriefridage += fraction;
|
|
feriefridageBalance -= fraction;
|
|
}
|
|
};
|
|
|
|
const useFlex = () => {
|
|
if (allowUnpaid && flexBalance < dayHours) {
|
|
const useHours = Math.max(0, flexBalance);
|
|
result.usedFlex += useHours;
|
|
result.usedFlexDays += useHours / baseDayHours;
|
|
result.usedUnpaid += fraction - (useHours / baseDayHours);
|
|
flexBalance = 0;
|
|
} else {
|
|
result.usedFlex += dayHours;
|
|
result.usedFlexDays += fraction;
|
|
flexBalance -= dayHours;
|
|
}
|
|
};
|
|
|
|
if (source === 'unpaid') {
|
|
result.usedUnpaid = fraction;
|
|
} else if (source === 'vacation') {
|
|
useVacation();
|
|
} else if (source === 'feriefridag') {
|
|
useFeriefridag();
|
|
} else if (source === 'flex') {
|
|
useFlex();
|
|
} else {
|
|
let remaining = fraction;
|
|
|
|
if (remaining > 0 && feriefridageBalance > 0 && isBeforeFeriefridageExpiry(date, settings)) {
|
|
const use = Math.min(feriefridageBalance, remaining);
|
|
result.usedFeriefridage += use;
|
|
feriefridageBalance -= use;
|
|
remaining -= use;
|
|
}
|
|
|
|
if (remaining > 0 && vacationBalance > 0) {
|
|
const use = Math.min(vacationBalance, remaining);
|
|
result.usedVacation += use;
|
|
vacationBalance -= use;
|
|
remaining -= use;
|
|
}
|
|
|
|
if (remaining > 0 && flexBalance > 0) {
|
|
const use = Math.min(flexBalance / baseDayHours, remaining);
|
|
result.usedFlex += use * baseDayHours;
|
|
result.usedFlexDays += use;
|
|
flexBalance -= use * baseDayHours;
|
|
remaining -= use;
|
|
}
|
|
|
|
if (remaining > 0) {
|
|
if (allowUnpaid) {
|
|
result.usedUnpaid += remaining;
|
|
} else {
|
|
result.usedVacation += remaining;
|
|
vacationBalance -= remaining;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ...result, balances: { vacationBalance, feriefridageBalance, flexBalance } };
|
|
}
|
|
|
|
function isBeforeFeriefridageExpiry(date, settings) {
|
|
return date.getMonth() + 1 <= Number(settings.feriefridage_expiry_month || 8);
|
|
}
|
|
|
|
function workdaysBetween(start, end) {
|
|
const days = [];
|
|
let current = parseDate(start);
|
|
const stop = parseDate(end);
|
|
while (current <= stop) {
|
|
const dow = current.getDay();
|
|
if (dow >= 1 && dow <= 5) days.push(new Date(current));
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
return days;
|
|
}
|
|
|
|
function workdayUnitsForEntry(entry) {
|
|
const units = workdaysBetween(entry.start_date, entry.end_date).map((date) => ({ date, fraction: 1 }));
|
|
let remaining = Math.max(0, Number(entry.non_working_days || 0));
|
|
for (let i = units.length - 1; i >= 0 && remaining > 0; i--) {
|
|
const reduction = Math.min(units[i].fraction, remaining);
|
|
units[i].fraction -= reduction;
|
|
remaining -= reduction;
|
|
}
|
|
return units.filter((unit) => unit.fraction > 0.0001);
|
|
}
|
|
|
|
function sumFractions(units) {
|
|
return units.reduce((sum, unit) => sum + unit.fraction, 0);
|
|
}
|
|
|
|
function hoursForDate(date, settings) {
|
|
return date.getDay() === 5 ? Number(settings.friday_hours || 7) : Number(settings.normal_day_hours || 7.5);
|
|
}
|
|
|
|
function averageDayHours(settings) {
|
|
return ((Number(settings.normal_day_hours || 7.5) * 4) + Number(settings.friday_hours || 7)) / 5;
|
|
}
|
|
|
|
function dateFromParts(year, month, day) {
|
|
return new Date(year, month - 1, day, 12, 0, 0);
|
|
}
|
|
|
|
function toIsoDate(date) {
|
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function parseDate(value) {
|
|
const [year, month, day] = value.split('-').map(Number);
|
|
return dateFromParts(year, month, day);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const [year, month, day] = String(value).split('-');
|
|
if (!year || !month || !day) return escapeHtml(value);
|
|
return `${day}/${month}/${year}`;
|
|
}
|
|
|
|
function monthKey(year, month) {
|
|
return `${year}-${String(month).padStart(2, '0')}`;
|
|
}
|
|
|
|
function correctionFor(corrections, year, month) {
|
|
return Number(corrections[monthKey(year, month)] || 0);
|
|
}
|
|
|
|
function hasCorrection(corrections, year, month) {
|
|
return Object.prototype.hasOwnProperty.call(corrections, monthKey(year, month));
|
|
}
|
|
|
|
function normalizeEntry(entry) {
|
|
return {
|
|
...entry,
|
|
id: Number(entry.id),
|
|
allow_unpaid: Number(entry.allow_unpaid),
|
|
non_working_days: Number(entry.non_working_days || 0),
|
|
status: entry.status === 'confirmed' ? 'approved' : entry.status,
|
|
};
|
|
}
|
|
|
|
function days(value) {
|
|
return formatNumber(value, 2);
|
|
}
|
|
|
|
function hours(value) {
|
|
return `${formatNumber(value, 2)}h`;
|
|
}
|
|
|
|
function parseDecimal(value) {
|
|
const normalized = String(value ?? '').trim().replace(',', '.');
|
|
if (normalized === '' || normalized === '+' || normalized === '-') return 0;
|
|
const number = Number(normalized);
|
|
return Number.isFinite(number) ? number : NaN;
|
|
}
|
|
|
|
function formatNumber(value, decimals = 2) {
|
|
const n = Number(value || 0);
|
|
const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
|
return new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(rounded);
|
|
}
|
|
|
|
function dedupeWarnings(warnings) {
|
|
const seen = new Set();
|
|
return warnings.filter((warning) => {
|
|
if (seen.has(warning.text)) return false;
|
|
seen.add(warning.text);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
|
|
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
|
}[char]));
|
|
}
|
|
|
|
function toast(message) {
|
|
const node = $('toast');
|
|
node.textContent = message;
|
|
node.classList.remove('hidden');
|
|
clearTimeout(toast.timer);
|
|
toast.timer = setTimeout(() => node.classList.add('hidden'), 2200);
|
|
}
|
|
|
|
window.editEntry = editEntry;
|
|
window.approveEntry = approveEntry;
|
|
window.deleteEntry = deleteEntry;
|