Calculations and inputs corrected
This commit is contained in:
parent
2ea0d9363e
commit
9b717ee6d6
4 changed files with 174 additions and 54 deletions
90
api.php
90
api.php
|
|
@ -20,6 +20,8 @@ try {
|
||||||
'settings' => get_settings($db),
|
'settings' => get_settings($db),
|
||||||
'entries' => get_entries($db),
|
'entries' => get_entries($db),
|
||||||
'corrections' => get_corrections($db),
|
'corrections' => get_corrections($db),
|
||||||
|
'feriefridage_overrides' => get_feriefridage_overrides($db),
|
||||||
|
'flex_overrides' => get_flex_overrides($db),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
fail('Unknown GET action', 404);
|
fail('Unknown GET action', 404);
|
||||||
|
|
@ -39,10 +41,20 @@ try {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'saveCorrection':
|
case 'saveCorrection':
|
||||||
save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), (float)($payload['value'] ?? 0));
|
save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
||||||
respond(['ok' => true, 'corrections' => get_corrections($db)]);
|
respond(['ok' => true, 'corrections' => get_corrections($db)]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'saveFeriefridageOverride':
|
||||||
|
save_feriefridage_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
||||||
|
respond(['ok' => true, 'feriefridage_overrides' => get_feriefridage_overrides($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'saveFlexOverride':
|
||||||
|
save_flex_override($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), $payload['value'] ?? null);
|
||||||
|
respond(['ok' => true, 'flex_overrides' => get_flex_overrides($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'createEntry':
|
case 'createEntry':
|
||||||
$id = save_entry($db, $payload['entry'] ?? []);
|
$id = save_entry($db, $payload['entry'] ?? []);
|
||||||
respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]);
|
respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]);
|
||||||
|
|
@ -117,6 +129,20 @@ function migrate(PDO $db): void
|
||||||
PRIMARY KEY (year, month)
|
PRIMARY KEY (year, month)
|
||||||
)");
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_feriefridage_overrides (
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL,
|
||||||
|
feriefridage_days REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (year, month)
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_flex_overrides (
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL,
|
||||||
|
flex_hours REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (year, month)
|
||||||
|
)");
|
||||||
|
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'planning_start_year' => '2026',
|
'planning_start_year' => '2026',
|
||||||
'planning_start_month' => '1',
|
'planning_start_month' => '1',
|
||||||
|
|
@ -191,13 +217,13 @@ function get_corrections(PDO $db): array
|
||||||
return $corrections;
|
return $corrections;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save_correction(PDO $db, int $year, int $month, float $value): void
|
function save_correction(PDO $db, int $year, int $month, mixed $value): void
|
||||||
{
|
{
|
||||||
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
||||||
fail('Invalid correction year/month', 400);
|
fail('Invalid correction year/month', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abs($value) < 0.000001) {
|
if ($value === null || $value === '') {
|
||||||
$stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month');
|
$stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month');
|
||||||
$stmt->execute([':year' => $year, ':month' => $month]);
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -206,7 +232,63 @@ function save_correction(PDO $db, int $year, int $month, float $value): void
|
||||||
$stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days)
|
$stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days)
|
||||||
VALUES (:year, :month, :value)
|
VALUES (:year, :month, :value)
|
||||||
ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days');
|
ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days');
|
||||||
$stmt->execute([':year' => $year, ':month' => $month, ':value' => $value]);
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_feriefridage_overrides(PDO $db): array
|
||||||
|
{
|
||||||
|
$rows = $db->query('SELECT year, month, feriefridage_days FROM monthly_feriefridage_overrides ORDER BY year, month')->fetchAll();
|
||||||
|
$overrides = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['feriefridage_days'];
|
||||||
|
}
|
||||||
|
return $overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_feriefridage_override(PDO $db, int $year, int $month, mixed $value): void
|
||||||
|
{
|
||||||
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
||||||
|
fail('Invalid feriefridage override year/month', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$stmt = $db->prepare('DELETE FROM monthly_feriefridage_overrides WHERE year = :year AND month = :month');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('INSERT INTO monthly_feriefridage_overrides (year, month, feriefridage_days)
|
||||||
|
VALUES (:year, :month, :value)
|
||||||
|
ON CONFLICT(year, month) DO UPDATE SET feriefridage_days = excluded.feriefridage_days');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_flex_overrides(PDO $db): array
|
||||||
|
{
|
||||||
|
$rows = $db->query('SELECT year, month, flex_hours FROM monthly_flex_overrides ORDER BY year, month')->fetchAll();
|
||||||
|
$overrides = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$overrides[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['flex_hours'];
|
||||||
|
}
|
||||||
|
return $overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_flex_override(PDO $db, int $year, int $month, mixed $value): void
|
||||||
|
{
|
||||||
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
||||||
|
fail('Invalid FLEX override year/month', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
$stmt = $db->prepare('DELETE FROM monthly_flex_overrides WHERE year = :year AND month = :month');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('INSERT INTO monthly_flex_overrides (year, month, flex_hours)
|
||||||
|
VALUES (:year, :month, :value)
|
||||||
|
ON CONFLICT(year, month) DO UPDATE SET flex_hours = excluded.flex_hours');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => (float)$value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_entries(PDO $db): array
|
function get_entries(PDO $db): array
|
||||||
|
|
|
||||||
127
app.js
127
app.js
|
|
@ -2,6 +2,8 @@ const state = {
|
||||||
settings: {},
|
settings: {},
|
||||||
entries: [],
|
entries: [],
|
||||||
corrections: {},
|
corrections: {},
|
||||||
|
feriefridageOverrides: {},
|
||||||
|
flexOverrides: {},
|
||||||
plan: null,
|
plan: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,7 +51,9 @@ async function loadState() {
|
||||||
state.settings = normalizeSettings(data.settings);
|
state.settings = normalizeSettings(data.settings);
|
||||||
state.entries = data.entries.map(normalizeEntry);
|
state.entries = data.entries.map(normalizeEntry);
|
||||||
state.corrections = data.corrections || {};
|
state.corrections = data.corrections || {};
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.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();
|
renderAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,34 +88,39 @@ async function saveSettings(event) {
|
||||||
}
|
}
|
||||||
const data = await post({ action: 'saveSettings', settings });
|
const data = await post({ action: 'saveSettings', settings });
|
||||||
state.settings = normalizeSettings(data.settings);
|
state.settings = normalizeSettings(data.settings);
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
||||||
renderAll();
|
renderAll();
|
||||||
closeSettingsDialog();
|
closeSettingsDialog();
|
||||||
toast('Settings saved');
|
toast('Settings saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveQuickFlex(event) {
|
async function saveBalanceOverride(event) {
|
||||||
event.preventDefault();
|
|
||||||
const value = Number($('quickFlexHours').value);
|
|
||||||
const data = await post({ action: 'saveSettings', settings: { current_flex_hours: value } });
|
|
||||||
state.settings = normalizeSettings(data.settings);
|
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
|
||||||
renderAll();
|
|
||||||
toast('FLEX hours updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCorrection(event) {
|
|
||||||
const input = event.target;
|
const input = event.target;
|
||||||
const year = Number(input.dataset.year);
|
const year = Number(input.dataset.year);
|
||||||
const month = Number(input.dataset.month);
|
const month = Number(input.dataset.month);
|
||||||
const value = Number(input.value || 0);
|
const kind = input.dataset.kind;
|
||||||
const data = await post({ action: 'saveCorrection', year, month, value });
|
const rawValue = String(input.value ?? '').trim();
|
||||||
state.corrections = data.corrections || {};
|
const value = rawValue === '' ? null : parseDecimal(rawValue);
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
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();
|
renderAll();
|
||||||
toast('Correction saved');
|
toast('Balance saved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function saveEntry(event) {
|
async function saveEntry(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = Number($('entryId').value || 0);
|
const id = Number($('entryId').value || 0);
|
||||||
|
|
@ -128,7 +137,7 @@ async function saveEntry(event) {
|
||||||
const payload = id ? { action: 'updateEntry', id, entry } : { action: 'createEntry', entry };
|
const payload = id ? { action: 'updateEntry', id, entry } : { action: 'createEntry', entry };
|
||||||
const data = await post(payload);
|
const data = await post(payload);
|
||||||
state.entries = data.entries.map(normalizeEntry);
|
state.entries = data.entries.map(normalizeEntry);
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
||||||
resetEntryForm();
|
resetEntryForm();
|
||||||
closeEntryDialog();
|
closeEntryDialog();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
|
@ -140,7 +149,7 @@ async function approveEntry(id) {
|
||||||
if (!entry || entry.status !== 'planned') return;
|
if (!entry || entry.status !== 'planned') return;
|
||||||
const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } });
|
const data = await post({ action: 'updateEntry', id, entry: { ...entry, status: 'approved' } });
|
||||||
state.entries = data.entries.map(normalizeEntry);
|
state.entries = data.entries.map(normalizeEntry);
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
||||||
renderAll();
|
renderAll();
|
||||||
toast('Entry approved');
|
toast('Entry approved');
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +159,7 @@ async function deleteEntry(id) {
|
||||||
if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return;
|
if (!confirm(`Delete "${entry?.title || 'entry'}"?`)) return;
|
||||||
const data = await post({ action: 'deleteEntry', id });
|
const data = await post({ action: 'deleteEntry', id });
|
||||||
state.entries = data.entries.map(normalizeEntry);
|
state.entries = data.entries.map(normalizeEntry);
|
||||||
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections, state.feriefridageOverrides, state.flexOverrides);
|
||||||
renderAll();
|
renderAll();
|
||||||
toast('Entry deleted');
|
toast('Entry deleted');
|
||||||
}
|
}
|
||||||
|
|
@ -230,13 +239,10 @@ function renderDashboard() {
|
||||||
<div class="hint">${escapeHtml(hint)}</div>
|
<div class="hint">${escapeHtml(hint)}</div>
|
||||||
</article>
|
</article>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
<article class="card ${current.flex < -0.001 ? 'bad' : 'good'} quick-flex-card">
|
<article class="card ${current.flex < -0.001 ? 'bad' : 'good'}">
|
||||||
<div class="label">FLEX hours</div>
|
<div class="label">FLEX hours</div>
|
||||||
<form id="quickFlexForm" class="quick-flex-form">
|
<div class="value">${hours(current.flex)}</div>
|
||||||
<input id="quickFlexHours" type="number" step="0.25" value="${Number(state.settings.current_flex_hours ?? state.settings.opening_flex_hours ?? 0)}" aria-label="Current FLEX hours">
|
<div class="hint">Current balance</div>
|
||||||
<button type="submit" class="small">Save</button>
|
|
||||||
</form>
|
|
||||||
<div class="hint">Current, used from today</div>
|
|
||||||
</article>
|
</article>
|
||||||
<article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}">
|
<article class="card ${current.totalDays < -0.001 ? 'bad' : 'good'}">
|
||||||
<div class="label">Total days</div>
|
<div class="label">Total days</div>
|
||||||
|
|
@ -244,7 +250,6 @@ function renderDashboard() {
|
||||||
<div class="hint">Vacation + FF + FLEX</div>
|
<div class="hint">Vacation + FF + FLEX</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
$('quickFlexForm').addEventListener('submit', saveQuickFlex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeaderWarnings() {
|
function renderHeaderWarnings() {
|
||||||
|
|
@ -307,8 +312,8 @@ function renderPlanner() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.querySelectorAll('.correction-input').forEach((input) => {
|
document.querySelectorAll('.balance-override-input').forEach((input) => {
|
||||||
input.addEventListener('change', saveCorrection);
|
input.addEventListener('change', (event) => saveBalanceOverride(event).catch((error) => toast(error.message || String(error))));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,16 +333,15 @@ function renderMonthRow(month) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>${month.year} - ${monthNames[month.month - 1]}${month.expiredFeriefridage > 0 ? `<br><span class="warn-text">Expired FF: ${days(month.expiredFeriefridage)}</span>` : ''}</td>
|
<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="entries">${entries}</td>
|
||||||
<td class="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}">${days(month.vacationBalance)}</td>
|
<td class="num ${month.vacationBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" 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' : ''}">${days(month.feriefridageBalance)}</td>
|
<td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" 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' : ''}">${hours(month.flexBalance)}</td>
|
<td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}"><input class="balance-override-input" 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 correction-cell"><input class="correction-input" type="number" step="0.01" value="${Number(month.balanceCorrection || 0)}" data-year="${month.year}" data-month="${month.month}" aria-label="Correction for ${monthNames[month.month - 1]} ${month.year}"></td>
|
|
||||||
<td class="num important-col ${balanceClass}">${days(month.availableDays)}</td>
|
<td class="num important-col ${balanceClass}">${days(month.availableDays)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculatePlan(settings, entries, corrections = {}) {
|
function calculatePlan(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) {
|
||||||
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
||||||
const previousMonths = Number(settings.previous_months ?? 5);
|
const previousMonths = Number(settings.previous_months ?? 5);
|
||||||
const futureMonths = Number(settings.future_months ?? 30);
|
const futureMonths = Number(settings.future_months ?? 30);
|
||||||
|
|
@ -385,7 +389,12 @@ function calculatePlan(settings, entries, corrections = {}) {
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
earnedVacation: Number(settings.vacation_days_per_month || 0),
|
earnedVacation: Number(settings.vacation_days_per_month || 0),
|
||||||
balanceCorrection: correctionFor(corrections, year, month),
|
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,
|
grantedFeriefridage: month === 1 ? Number(settings.feriefridage_per_year || 0) : 0,
|
||||||
expiredFeriefridage: 0,
|
expiredFeriefridage: 0,
|
||||||
usedVacation: 0,
|
usedVacation: 0,
|
||||||
|
|
@ -399,12 +408,13 @@ function calculatePlan(settings, entries, corrections = {}) {
|
||||||
entries: [],
|
entries: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
vacationBalance += current.earnedVacation + current.balanceCorrection;
|
|
||||||
feriefridageBalance += current.grantedFeriefridage;
|
feriefridageBalance += current.grantedFeriefridage;
|
||||||
|
|
||||||
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
||||||
current.expiredFeriefridage = feriefridageBalance;
|
current.expiredFeriefridage = feriefridageBalance;
|
||||||
warnings.push({ level: 'bad', text: `${days(feriefridageBalance)} feriefridage expired on 31 August ${year}. They must be used before that date.` });
|
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;
|
feriefridageBalance = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -466,14 +476,21 @@ function calculatePlan(settings, entries, corrections = {}) {
|
||||||
monthItems.forEach(applyWorkday);
|
monthItems.forEach(applyWorkday);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (month === Number(settings.feriefridage_expiry_month || 8) && feriefridageBalance > 0.001) {
|
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.` });
|
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.vacationBalance = vacationBalance;
|
||||||
current.feriefridageBalance = feriefridageBalance;
|
current.feriefridageBalance = feriefridageBalance;
|
||||||
current.flexBalance = flexBalance;
|
current.flexBalance = flexBalance;
|
||||||
current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
current.availableDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
||||||
|
|
||||||
months.push(current);
|
months.push(current);
|
||||||
|
|
||||||
if (vacationBalance < -0.001) {
|
if (vacationBalance < -0.001) {
|
||||||
|
|
@ -499,13 +516,13 @@ function calculatePlan(settings, entries, corrections = {}) {
|
||||||
return {
|
return {
|
||||||
months,
|
months,
|
||||||
warnings: dedupeWarnings(warnings),
|
warnings: dedupeWarnings(warnings),
|
||||||
current: calculateCurrentSnapshot(settings, entries, corrections),
|
current: calculateCurrentSnapshot(settings, entries, corrections, feriefridageOverrides, flexOverrides),
|
||||||
entrySummaries,
|
entrySummaries,
|
||||||
final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays },
|
final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateCurrentSnapshot(settings, entries, corrections = {}) {
|
function calculateCurrentSnapshot(settings, entries, corrections = {}, feriefridageOverrides = {}, flexOverrides = {}) {
|
||||||
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
||||||
const previousMonths = Number(settings.previous_months ?? 5);
|
const previousMonths = Number(settings.previous_months ?? 5);
|
||||||
const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1);
|
const startCursor = dateFromParts(today.getFullYear(), today.getMonth() + 1 - previousMonths, 1);
|
||||||
|
|
@ -541,7 +558,6 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}) {
|
||||||
const year = cursor.getFullYear();
|
const year = cursor.getFullYear();
|
||||||
const month = cursor.getMonth() + 1;
|
const month = cursor.getMonth() + 1;
|
||||||
|
|
||||||
vacationBalance += Number(settings.vacation_days_per_month || 0) + correctionFor(corrections, year, month);
|
|
||||||
if (month === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0);
|
if (month === 1) feriefridageBalance += Number(settings.feriefridage_per_year || 0);
|
||||||
|
|
||||||
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
||||||
|
|
@ -558,9 +574,23 @@ function calculateCurrentSnapshot(settings, entries, corrections = {}) {
|
||||||
feriefridageBalance = allocation.balances.feriefridageBalance;
|
feriefridageBalance = allocation.balances.feriefridageBalance;
|
||||||
flexBalance = allocation.balances.flexBalance;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
flexBalance = currentFlexHours;
|
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 (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 (feriefridageBalance < -0.001) warnings.push({ level: 'bad', text: `Feriefridage balance is negative today: ${days(feriefridageBalance)} days.` });
|
||||||
|
|
@ -687,6 +717,10 @@ function correctionFor(corrections, year, month) {
|
||||||
return Number(corrections[monthKey(year, month)] || 0);
|
return Number(corrections[monthKey(year, month)] || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCorrection(corrections, year, month) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(corrections, monthKey(year, month));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEntry(entry) {
|
function normalizeEntry(entry) {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
|
|
@ -704,6 +738,13 @@ function hours(value) {
|
||||||
return `${formatNumber(value, 2)}h`;
|
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) {
|
function formatNumber(value, decimals = 2) {
|
||||||
const n = Number(value || 0);
|
const n = Number(value || 0);
|
||||||
const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
const rounded = Math.round((n + Number.EPSILON) * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
<th class="num">Vacation days</th>
|
<th class="num">Vacation days</th>
|
||||||
<th class="num">Feriefridage</th>
|
<th class="num">Feriefridage</th>
|
||||||
<th class="num">FLEX</th>
|
<th class="num">FLEX</th>
|
||||||
<th class="num">Correction</th>
|
|
||||||
<th class="num important-col">Available days</th>
|
<th class="num important-col">Available days</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -124,7 +123,7 @@
|
||||||
<label>Start vacation balance<input id="opening_vacation_days" type="number" step="0.01"></label>
|
<label>Start vacation balance<input id="opening_vacation_days" type="number" step="0.01"></label>
|
||||||
<label>Start feriefridage balance<input id="opening_feriefridage_days" type="number" step="0.01"></label>
|
<label>Start feriefridage balance<input id="opening_feriefridage_days" type="number" step="0.01"></label>
|
||||||
<label>Start FLEX hours<input id="opening_flex_hours" type="number" step="0.25"></label>
|
<label>Start FLEX hours<input id="opening_flex_hours" type="number" step="0.25"></label>
|
||||||
<p class="full help-text">Start balances are the balances before the first shown month. Monthly corrections are edited directly in the monthly planner table.</p>
|
<p class="full help-text">Start balances are the balances before the first shown month. Monthly vacation, feriefridage, and FLEX balances are edited directly in the planner table.</p>
|
||||||
<div class="actions full">
|
<div class="actions full">
|
||||||
<button type="submit">Save settings</button>
|
<button type="submit">Save settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -267,8 +267,7 @@ table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
||||||
.month-table th:nth-child(3), .month-table td:nth-child(3),
|
.month-table th:nth-child(3), .month-table td:nth-child(3),
|
||||||
.month-table th:nth-child(4), .month-table td:nth-child(4),
|
.month-table th:nth-child(4), .month-table td:nth-child(4),
|
||||||
.month-table th:nth-child(5), .month-table td:nth-child(5),
|
.month-table th:nth-child(5), .month-table td:nth-child(5),
|
||||||
.month-table th:nth-child(6), .month-table td:nth-child(6),
|
.month-table th:nth-child(6), .month-table td:nth-child(6) { width: 7.2rem; }
|
||||||
.month-table th:nth-child(7), .month-table td:nth-child(7) { width: 7.2rem; }
|
|
||||||
th, td { padding: .75rem .65rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
th, td { padding: .75rem .65rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
||||||
th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; }
|
th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
tbody tr:hover { background: rgba(255,255,255,.04); }
|
tbody tr:hover { background: rgba(255,255,255,.04); }
|
||||||
|
|
@ -293,9 +292,8 @@ tbody tr:hover { background: rgba(255,255,255,.04); }
|
||||||
.pill.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); }
|
.pill.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); }
|
||||||
.pill.unpaid { background: rgba(201,135,74,.22); color: var(--brand-dark); }
|
.pill.unpaid { background: rgba(201,135,74,.22); color: var(--brand-dark); }
|
||||||
.important-col { background: rgba(163,138,99,.13); font-size: 1rem; }
|
.important-col { background: rgba(163,138,99,.13); font-size: 1rem; }
|
||||||
.correction-cell { min-width: 5.2rem; }
|
.balance-override-input {
|
||||||
.correction-input {
|
width: 5.2rem;
|
||||||
width: 4.8rem;
|
|
||||||
padding: .28rem .35rem;
|
padding: .28rem .35rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue