First version
This commit is contained in:
parent
19bb4146da
commit
0727798af7
7 changed files with 1581 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
GO.xlsx
|
||||||
|
data/*
|
||||||
|
!data/.htaccess
|
||||||
|
|
|
||||||
27
README.md
Normal file
27
README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# GO Vacation Planner
|
||||||
|
|
||||||
|
Small PHP + SQLite web app for planning Danish vacation days, feriefridage, FLEX hours, and explicit unpaid fallback per date range.
|
||||||
|
|
||||||
|
## Local run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:8080>.
|
||||||
|
|
||||||
|
The SQLite database is created automatically in `data/go.sqlite`.
|
||||||
|
|
||||||
|
## Rules currently modeled
|
||||||
|
|
||||||
|
- Vacation accrues monthly, default `2.08` days/month.
|
||||||
|
- Feriefridage grant yearly, default `5` days/year.
|
||||||
|
- Feriefridage must be used before `31 August` and expire before September planning.
|
||||||
|
- FLEX days use `7.5h` Monday-Thursday and `7h` Friday.
|
||||||
|
- Start balances are configured separately from current FLEX hours.
|
||||||
|
- Monthly vacation-day corrections can be entered, e.g. `+1.2` or `-2.6` days.
|
||||||
|
- Each date-range entry can individually allow unpaid fallback so only that range is converted to unpaid days if balances are insufficient.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
Runtime database files are ignored by git. Back up `data/go.sqlite` if needed.
|
||||||
301
api.php
Normal file
301
api.php
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const DB_DIR = __DIR__ . '/data';
|
||||||
|
const DB_FILE = DB_DIR . '/go.sqlite';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = db();
|
||||||
|
migrate($db);
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
$action = $_GET['action'] ?? null;
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
if ($action === null || $action === 'state') {
|
||||||
|
respond([
|
||||||
|
'settings' => get_settings($db),
|
||||||
|
'entries' => get_entries($db),
|
||||||
|
'corrections' => get_corrections($db),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
fail('Unknown GET action', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$payload = json_decode(file_get_contents('php://input') ?: '{}', true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
fail('Invalid JSON payload', 400);
|
||||||
|
}
|
||||||
|
$action = $payload['action'] ?? $action;
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'saveSettings':
|
||||||
|
save_settings($db, $payload['settings'] ?? []);
|
||||||
|
respond(['ok' => true, 'settings' => get_settings($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'saveCorrection':
|
||||||
|
save_correction($db, (int)($payload['year'] ?? 0), (int)($payload['month'] ?? 0), (float)($payload['value'] ?? 0));
|
||||||
|
respond(['ok' => true, 'corrections' => get_corrections($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'createEntry':
|
||||||
|
$id = save_entry($db, $payload['entry'] ?? []);
|
||||||
|
respond(['ok' => true, 'id' => $id, 'entries' => get_entries($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'updateEntry':
|
||||||
|
$id = (int)($payload['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
fail('Missing entry id', 400);
|
||||||
|
}
|
||||||
|
save_entry($db, $payload['entry'] ?? [], $id);
|
||||||
|
respond(['ok' => true, 'entries' => get_entries($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'deleteEntry':
|
||||||
|
$id = (int)($payload['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
fail('Missing entry id', 400);
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare('DELETE FROM entries WHERE id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
respond(['ok' => true, 'entries' => get_entries($db)]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
fail('Unknown POST action', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fail('Unsupported method', 405);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fail($e->getMessage(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function db(): PDO
|
||||||
|
{
|
||||||
|
if (!is_dir(DB_DIR) && !mkdir(DB_DIR, 0775, true) && !is_dir(DB_DIR)) {
|
||||||
|
throw new RuntimeException('Could not create data directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = new PDO('sqlite:' . DB_FILE);
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$db->exec('PRAGMA foreign_keys = ON');
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(PDO $db): void
|
||||||
|
{
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'auto',
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned',
|
||||||
|
allow_unpaid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)");
|
||||||
|
|
||||||
|
$db->exec("CREATE TABLE IF NOT EXISTS monthly_corrections (
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL,
|
||||||
|
vacation_days REAL NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (year, month)
|
||||||
|
)");
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'planning_start_year' => '2026',
|
||||||
|
'planning_start_month' => '1',
|
||||||
|
'planning_years' => '3',
|
||||||
|
'planning_months' => '36',
|
||||||
|
'vacation_days_per_month' => '2.08',
|
||||||
|
'feriefridage_per_year' => '5',
|
||||||
|
'feriefridage_expiry_month' => '8',
|
||||||
|
'normal_day_hours' => '7.5',
|
||||||
|
'friday_hours' => '7',
|
||||||
|
'opening_vacation_days' => '-1.97',
|
||||||
|
'opening_feriefridage_days' => '0',
|
||||||
|
'opening_flex_hours' => '0',
|
||||||
|
'current_flex_hours' => '46.5',
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $db->prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)');
|
||||||
|
foreach ($defaults as $key => $value) {
|
||||||
|
$stmt->execute([':key' => $key, ':value' => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_settings(PDO $db): array
|
||||||
|
{
|
||||||
|
$rows = $db->query('SELECT key, value FROM settings')->fetchAll();
|
||||||
|
$settings = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$settings[$row['key']] = numeric_value($row['value']);
|
||||||
|
}
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_settings(PDO $db, array $settings): void
|
||||||
|
{
|
||||||
|
$allowed = [
|
||||||
|
'planning_start_year',
|
||||||
|
'planning_start_month',
|
||||||
|
'planning_years',
|
||||||
|
'planning_months',
|
||||||
|
'vacation_days_per_month',
|
||||||
|
'feriefridage_per_year',
|
||||||
|
'feriefridage_expiry_month',
|
||||||
|
'normal_day_hours',
|
||||||
|
'friday_hours',
|
||||||
|
'opening_vacation_days',
|
||||||
|
'opening_feriefridage_days',
|
||||||
|
'opening_flex_hours',
|
||||||
|
'current_flex_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $db->prepare('INSERT INTO settings (key, value) VALUES (:key, :value)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||||
|
|
||||||
|
foreach ($allowed as $key) {
|
||||||
|
if (array_key_exists($key, $settings)) {
|
||||||
|
$stmt->execute([':key' => $key, ':value' => (string)$settings[$key]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_corrections(PDO $db): array
|
||||||
|
{
|
||||||
|
$rows = $db->query('SELECT year, month, vacation_days FROM monthly_corrections ORDER BY year, month')->fetchAll();
|
||||||
|
$corrections = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$corrections[$row['year'] . '-' . str_pad((string)$row['month'], 2, '0', STR_PAD_LEFT)] = (float)$row['vacation_days'];
|
||||||
|
}
|
||||||
|
return $corrections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_correction(PDO $db, int $year, int $month, float $value): void
|
||||||
|
{
|
||||||
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
||||||
|
fail('Invalid correction year/month', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs($value) < 0.000001) {
|
||||||
|
$stmt = $db->prepare('DELETE FROM monthly_corrections WHERE year = :year AND month = :month');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('INSERT INTO monthly_corrections (year, month, vacation_days)
|
||||||
|
VALUES (:year, :month, :value)
|
||||||
|
ON CONFLICT(year, month) DO UPDATE SET vacation_days = excluded.vacation_days');
|
||||||
|
$stmt->execute([':year' => $year, ':month' => $month, ':value' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_entries(PDO $db): array
|
||||||
|
{
|
||||||
|
return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, note
|
||||||
|
FROM entries ORDER BY start_date, end_date, id')->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_entry(PDO $db, array $entry, ?int $id = null): int
|
||||||
|
{
|
||||||
|
$title = trim((string)($entry['title'] ?? ''));
|
||||||
|
$start = trim((string)($entry['start_date'] ?? ''));
|
||||||
|
$end = trim((string)($entry['end_date'] ?? ''));
|
||||||
|
$source = (string)($entry['source'] ?? 'auto');
|
||||||
|
$status = (string)($entry['status'] ?? 'planned');
|
||||||
|
$allowUnpaid = !empty($entry['allow_unpaid']) ? 1 : 0;
|
||||||
|
$note = trim((string)($entry['note'] ?? ''));
|
||||||
|
|
||||||
|
if ($title === '') {
|
||||||
|
fail('Title is required', 400);
|
||||||
|
}
|
||||||
|
if (!valid_date($start) || !valid_date($end)) {
|
||||||
|
fail('Start/end date must be YYYY-MM-DD', 400);
|
||||||
|
}
|
||||||
|
if ($start > $end) {
|
||||||
|
fail('Start date must be before end date', 400);
|
||||||
|
}
|
||||||
|
if (!in_array($source, ['auto', 'vacation', 'feriefridag', 'flex', 'unpaid'], true)) {
|
||||||
|
fail('Invalid source', 400);
|
||||||
|
}
|
||||||
|
if ($status === 'confirmed') {
|
||||||
|
$status = 'approved';
|
||||||
|
}
|
||||||
|
if (!in_array($status, ['planned', 'approved'], true)) {
|
||||||
|
fail('Invalid status', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
':title' => $title,
|
||||||
|
':start_date' => $start,
|
||||||
|
':end_date' => $end,
|
||||||
|
':source' => $source,
|
||||||
|
':status' => $status,
|
||||||
|
':allow_unpaid' => $allowUnpaid,
|
||||||
|
':note' => $note,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($id === null) {
|
||||||
|
$stmt = $db->prepare('INSERT INTO entries
|
||||||
|
(title, start_date, end_date, source, status, allow_unpaid, note)
|
||||||
|
VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :note)');
|
||||||
|
$stmt->execute($params);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[':id'] = $id;
|
||||||
|
$stmt = $db->prepare('UPDATE entries SET
|
||||||
|
title = :title,
|
||||||
|
start_date = :start_date,
|
||||||
|
end_date = :end_date,
|
||||||
|
source = :source,
|
||||||
|
status = :status,
|
||||||
|
allow_unpaid = :allow_unpaid,
|
||||||
|
note = :note,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :id');
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid_date(string $date): bool
|
||||||
|
{
|
||||||
|
$d = DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
return $d && $d->format('Y-m-d') === $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numeric_value(string $value): int|float|string
|
||||||
|
{
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return str_contains($value, '.') ? (float)$value : (int)$value;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(array $data): never
|
||||||
|
{
|
||||||
|
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(string $message, int $status): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode(['error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
733
app.js
Normal file
733
app.js
Normal file
|
|
@ -0,0 +1,733 @@
|
||||||
|
const state = {
|
||||||
|
settings: {},
|
||||||
|
entries: [],
|
||||||
|
corrections: {},
|
||||||
|
plan: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingKeys = [
|
||||||
|
'planning_start_year', 'planning_start_month', 'planning_months',
|
||||||
|
'vacation_days_per_month', 'feriefridage_per_year', 'feriefridage_expiry_month',
|
||||||
|
'normal_day_hours', 'friday_hours', 'opening_vacation_days',
|
||||||
|
'opening_feriefridage_days', 'opening_flex_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();
|
||||||
|
await loadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$('addEntryBtn').addEventListener('click', () => openEntryDialog());
|
||||||
|
$('settingsBtn').addEventListener('click', openSettingsDialog);
|
||||||
|
$('closeEntryDialogBtn').addEventListener('click', closeEntryDialog);
|
||||||
|
$('closeSettingsDialogBtn').addEventListener('click', closeSettingsDialog);
|
||||||
|
$('entryDialog').addEventListener('click', closeDialogOnBackdrop);
|
||||||
|
$('settingsDialog').addEventListener('click', closeDialogOnBackdrop);
|
||||||
|
$('entryForm').addEventListener('submit', saveEntry);
|
||||||
|
$('settingsForm').addEventListener('submit', saveSettings);
|
||||||
|
$('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.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettings(settings) {
|
||||||
|
const normalized = { ...settings };
|
||||||
|
normalized.planning_start_month = Number(normalized.planning_start_month || 1);
|
||||||
|
normalized.planning_months = Number(normalized.planning_months || (Number(normalized.planning_years || 3) * 12));
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
fillSettingsForm();
|
||||||
|
renderDashboard();
|
||||||
|
renderHeaderWarnings();
|
||||||
|
renderWarnings();
|
||||||
|
renderEntries();
|
||||||
|
renderPlanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
renderAll();
|
||||||
|
closeSettingsDialog();
|
||||||
|
toast('Settings saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQuickFlex(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 year = Number(input.dataset.year);
|
||||||
|
const month = Number(input.dataset.month);
|
||||||
|
const value = Number(input.value || 0);
|
||||||
|
const data = await post({ action: 'saveCorrection', year, month, value });
|
||||||
|
state.corrections = data.corrections || {};
|
||||||
|
state.plan = calculatePlan(state.settings, state.entries, state.corrections);
|
||||||
|
renderAll();
|
||||||
|
toast('Correction 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,
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
renderAll();
|
||||||
|
toast('Entry deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
$('note').value = entry.note || '';
|
||||||
|
$('entryFormTitle').textContent = 'Edit date-range entry';
|
||||||
|
$('saveEntryBtn').textContent = 'Update entry';
|
||||||
|
$('cancelEditBtn').classList.remove('hidden');
|
||||||
|
openEntryDialog(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEntryForm() {
|
||||||
|
$('entryForm').reset();
|
||||||
|
$('entryId').value = '';
|
||||||
|
$('source').value = 'auto';
|
||||||
|
$('status').value = 'planned';
|
||||||
|
$('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'} quick-flex-card">
|
||||||
|
<div class="label">FLEX hours</div>
|
||||||
|
<form id="quickFlexForm" class="quick-flex-form">
|
||||||
|
<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">
|
||||||
|
<button type="submit" class="small">Save</button>
|
||||||
|
</form>
|
||||||
|
<div class="hint">Current, used from today</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>
|
||||||
|
`;
|
||||||
|
$('quickFlexForm').addEventListener('submit', saveQuickFlex);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tbody = $('entriesTable').querySelector('tbody');
|
||||||
|
if (!state.entries.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="muted">No entries yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = state.entries.map((entry) => {
|
||||||
|
const summary = state.plan.entrySummaries[entry.id] || { workdays: 0, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0 };
|
||||||
|
const source = sourceLabels[entry.source] || entry.source;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(entry.title)}</strong> <span class="muted">${escapeHtml(source)}</span><br><span class="muted entry-date">${escapeHtml(entry.start_date)} → ${escapeHtml(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('.correction-input').forEach((input) => {
|
||||||
|
input.addEventListener('change', saveCorrection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">${escapeHtml(item.start_date)} → ${escapeHtml(item.end_date)}</small>
|
||||||
|
</span>
|
||||||
|
`).join('')
|
||||||
|
: '<span class="muted">—</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<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' : ''}">${days(month.vacationBalance)}</td>
|
||||||
|
<td class="num ${month.feriefridageBalance < -0.001 ? 'bad-text' : ''}">${days(month.feriefridageBalance)}</td>
|
||||||
|
<td class="num ${month.flexBalance < -0.001 ? 'bad-text' : ''}">${hours(month.flexBalance)}</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>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePlan(settings, entries, corrections = {}) {
|
||||||
|
const startYear = Number(settings.planning_start_year || new Date().getFullYear());
|
||||||
|
const startMonth = Number(settings.planning_start_month || 1);
|
||||||
|
const monthCount = Number(settings.planning_months || (Number(settings.planning_years || 3) * 12));
|
||||||
|
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 dates = workdaysBetween(entry.start_date, entry.end_date);
|
||||||
|
entrySummaries[entry.id] = { workdays: dates.length, usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 };
|
||||||
|
for (const date of dates) {
|
||||||
|
if (date < startDate || date > endDate) continue;
|
||||||
|
allWorkdays.push({ date, 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 today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
||||||
|
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),
|
||||||
|
balanceCorrection: correctionFor(corrections, 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: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
vacationBalance += current.earnedVacation + current.balanceCorrection;
|
||||||
|
feriefridageBalance += current.grantedFeriefridage;
|
||||||
|
|
||||||
|
if (month === Number(settings.feriefridage_expiry_month || 8) + 1 && feriefridageBalance > 0.001) {
|
||||||
|
current.expiredFeriefridage = feriefridageBalance;
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 += 1;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (month === Number(settings.feriefridage_expiry_month || 8) && 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.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
warnings: dedupeWarnings(warnings),
|
||||||
|
current: calculateCurrentSnapshot(settings, entries, corrections),
|
||||||
|
entrySummaries,
|
||||||
|
final: { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCurrentSnapshot(settings, entries, corrections = {}) {
|
||||||
|
const today = dateFromParts(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate());
|
||||||
|
const startYear = Number(settings.planning_start_year || today.getFullYear());
|
||||||
|
const startMonth = Number(settings.planning_start_month || 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 date of workdaysBetween(entry.start_date, entry.end_date)) {
|
||||||
|
if (date >= startDate && date <= today) workdays.push({ date, 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;
|
||||||
|
|
||||||
|
vacationBalance += Number(settings.vacation_days_per_month || 0) + correctionFor(corrections, year, month);
|
||||||
|
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);
|
||||||
|
vacationBalance = allocation.balances.vacationBalance;
|
||||||
|
feriefridageBalance = allocation.balances.feriefridageBalance;
|
||||||
|
flexBalance = allocation.balances.flexBalance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
warnings.push({ level: 'warn', text: `${days(feriefridageBalance)} feriefridage must be used before 31 August.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDays = vacationBalance + feriefridageBalance + (flexBalance / avgDayHours);
|
||||||
|
return { vacation: vacationBalance, feriefridage: feriefridageBalance, flex: flexBalance, totalDays, warnings: dedupeWarnings(warnings) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocateDay(entry, date, balances, settings) {
|
||||||
|
const source = entry.source;
|
||||||
|
const allowUnpaid = Boolean(Number(entry.allow_unpaid));
|
||||||
|
const dayHours = hoursForDate(date, settings);
|
||||||
|
let { vacationBalance, feriefridageBalance, flexBalance } = balances;
|
||||||
|
const result = { usedVacation: 0, usedFeriefridage: 0, usedFlex: 0, usedFlexDays: 0, usedUnpaid: 0 };
|
||||||
|
|
||||||
|
const useVacation = () => {
|
||||||
|
if (allowUnpaid && vacationBalance < 1) {
|
||||||
|
const use = Math.max(0, vacationBalance);
|
||||||
|
result.usedVacation += use;
|
||||||
|
result.usedUnpaid += 1 - use;
|
||||||
|
vacationBalance = 0;
|
||||||
|
} else {
|
||||||
|
result.usedVacation += 1;
|
||||||
|
vacationBalance -= 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFeriefridag = () => {
|
||||||
|
if (allowUnpaid && feriefridageBalance < 1) {
|
||||||
|
const use = Math.max(0, feriefridageBalance);
|
||||||
|
result.usedFeriefridage += use;
|
||||||
|
result.usedUnpaid += 1 - use;
|
||||||
|
feriefridageBalance = 0;
|
||||||
|
} else {
|
||||||
|
result.usedFeriefridage += 1;
|
||||||
|
feriefridageBalance -= 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFlex = () => {
|
||||||
|
if (allowUnpaid && flexBalance < dayHours) {
|
||||||
|
const useHours = Math.max(0, flexBalance);
|
||||||
|
result.usedFlex += useHours;
|
||||||
|
result.usedFlexDays += useHours / dayHours;
|
||||||
|
result.usedUnpaid += 1 - (useHours / dayHours);
|
||||||
|
flexBalance = 0;
|
||||||
|
} else {
|
||||||
|
result.usedFlex += dayHours;
|
||||||
|
result.usedFlexDays += 1;
|
||||||
|
flexBalance -= dayHours;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (source === 'unpaid') {
|
||||||
|
result.usedUnpaid = 1;
|
||||||
|
} else if (source === 'vacation') {
|
||||||
|
useVacation();
|
||||||
|
} else if (source === 'feriefridag') {
|
||||||
|
useFeriefridag();
|
||||||
|
} else if (source === 'flex') {
|
||||||
|
useFlex();
|
||||||
|
} else {
|
||||||
|
if (feriefridageBalance >= 1 && isBeforeFeriefridageExpiry(date, settings)) {
|
||||||
|
useFeriefridag();
|
||||||
|
} else if (vacationBalance >= 1) {
|
||||||
|
useVacation();
|
||||||
|
} else if (flexBalance >= dayHours) {
|
||||||
|
useFlex();
|
||||||
|
} else if (allowUnpaid) {
|
||||||
|
const use = Math.max(0, vacationBalance);
|
||||||
|
result.usedVacation += use;
|
||||||
|
result.usedUnpaid += 1 - use;
|
||||||
|
vacationBalance = 0;
|
||||||
|
} else {
|
||||||
|
useVacation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 parseDate(value) {
|
||||||
|
const [year, month, day] = value.split('-').map(Number);
|
||||||
|
return dateFromParts(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthKey(year, month) {
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function correctionFor(corrections, year, month) {
|
||||||
|
return Number(corrections[monthKey(year, month)] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(entry) {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
id: Number(entry.id),
|
||||||
|
allow_unpaid: Number(entry.allow_unpaid),
|
||||||
|
status: entry.status === 'confirmed' ? 'approved' : entry.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function days(value) {
|
||||||
|
return formatNumber(value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hours(value) {
|
||||||
|
return `${formatNumber(value, 2)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
1
data/.htaccess
Normal file
1
data/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Require all denied
|
||||||
138
index.html
Normal file
138
index.html
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>GO Vacation Planner</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="floating-header">
|
||||||
|
<section class="cards header-cards" id="dashboard" aria-live="polite"></section>
|
||||||
|
<section class="header-entries" aria-label="Entries">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="entriesTable" class="compact-entries-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry</th>
|
||||||
|
<th class="num">Days</th>
|
||||||
|
<th class="num">Vac. days</th>
|
||||||
|
<th class="num">FF</th>
|
||||||
|
<th class="num">FLEX</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="header-warning-row" aria-live="polite">
|
||||||
|
<span id="headerWarnings" class="header-warning-list"></span>
|
||||||
|
<span class="header-action-list">
|
||||||
|
<button id="addEntryBtn" class="header-action-button">Add entry</button>
|
||||||
|
<button id="settingsBtn" class="header-action-button">Settings</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="planner-column-header table-wrap" aria-label="Monthly planner column names">
|
||||||
|
<table class="month-table planner-header-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Month</th>
|
||||||
|
<th>Entries</th>
|
||||||
|
<th class="num">Vacation days</th>
|
||||||
|
<th class="num">Feriefridage</th>
|
||||||
|
<th class="num">FLEX</th>
|
||||||
|
<th class="num">Correction</th>
|
||||||
|
<th class="num important-col">Available days</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="panel planner-panel">
|
||||||
|
<div id="planner" class="years"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="entryDialog" class="modal">
|
||||||
|
<div class="modal-heading">
|
||||||
|
<h2 id="entryFormTitle">Add date-range entry</h2>
|
||||||
|
<button type="button" id="closeEntryDialogBtn" class="ghost small">Close</button>
|
||||||
|
</div>
|
||||||
|
<form id="entryForm" class="form-grid">
|
||||||
|
<input type="hidden" id="entryId">
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input id="title" required placeholder="e.g. Summer holiday">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start date
|
||||||
|
<input id="startDate" type="date" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End date
|
||||||
|
<input id="endDate" type="date" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Use source
|
||||||
|
<select id="source">
|
||||||
|
<option value="auto">Smart: feriefridage → vacation → FLEX</option>
|
||||||
|
<option value="feriefridag">Feriefridage only</option>
|
||||||
|
<option value="vacation">Vacation days only</option>
|
||||||
|
<option value="flex">FLEX only</option>
|
||||||
|
<option value="unpaid">Unpaid only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select id="status">
|
||||||
|
<option value="planned">Planned</option>
|
||||||
|
<option value="approved">Approved</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input id="allowUnpaid" type="checkbox">
|
||||||
|
<span>If this entry goes negative, convert missing days in this date range to unpaid</span>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
Note
|
||||||
|
<textarea id="note" rows="3" placeholder="Optional note"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="actions full">
|
||||||
|
<button type="button" id="cancelEditBtn" class="ghost hidden">Cancel edit</button>
|
||||||
|
<button type="submit" id="saveEntryBtn">Save entry</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="settingsDialog" class="modal wide">
|
||||||
|
<div class="modal-heading">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button type="button" id="closeSettingsDialogBtn" class="ghost small">Close</button>
|
||||||
|
</div>
|
||||||
|
<form id="settingsForm" class="form-grid compact">
|
||||||
|
<label>Start year<input id="planning_start_year" type="number" step="1"></label>
|
||||||
|
<label>Start month<input id="planning_start_month" type="number" step="1" min="1" max="12"></label>
|
||||||
|
<label>Months shown<input id="planning_months" type="number" step="1" min="1" max="120"></label>
|
||||||
|
<label>Vacation/month<input id="vacation_days_per_month" type="number" step="0.01"></label>
|
||||||
|
<label>Feriefridage/year<input id="feriefridage_per_year" type="number" step="0.01"></label>
|
||||||
|
<label>FF deadline month<input id="feriefridage_expiry_month" type="number" step="1" min="1" max="12"></label>
|
||||||
|
<label>Mon–Thu hours<input id="normal_day_hours" type="number" step="0.25"></label>
|
||||||
|
<label>Friday hours<input id="friday_hours" type="number" step="0.25"></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 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>
|
||||||
|
<div class="actions full">
|
||||||
|
<button type="submit">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
378
style.css
Normal file
378
style.css
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
:root {
|
||||||
|
--bg: #111315;
|
||||||
|
--panel: #1B1E22;
|
||||||
|
--ink: #F3F4F6;
|
||||||
|
--muted: #A0A4AB;
|
||||||
|
--brand: #C46A3A;
|
||||||
|
--brand-dark: #d97b48;
|
||||||
|
--accent: #3BA7A0;
|
||||||
|
--good: #3BA7A0;
|
||||||
|
--warn: #C46A3A;
|
||||||
|
--bad: #B32626;
|
||||||
|
--line: rgba(59,167,160,.35);
|
||||||
|
--shadow: 0 2px 16px rgba(0, 0, 0, 0.55);
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: radial-gradient(circle at top left, rgba(59,167,160,.16), transparent 32rem), radial-gradient(circle at top right, rgba(196,106,58,.14), transparent 30rem), var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
padding: .35rem clamp(.45rem, 2vw, 1.25rem);
|
||||||
|
background: linear-gradient(180deg, rgba(27,30,34,.94), rgba(27,30,34,.78));
|
||||||
|
border-bottom: 1px solid rgba(59,167,160,.35);
|
||||||
|
box-shadow: 0 2px 16px #0008;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 .15rem;
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .1em;
|
||||||
|
font-size: .68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 0; font-size: clamp(1.05rem, 2.2vw, 1.45rem); line-height: 1; }
|
||||||
|
h2 { margin-bottom: 0; font-size: 1.1rem; }
|
||||||
|
.subhead { color: var(--muted); max-width: 58rem; margin-bottom: 0; }
|
||||||
|
.hero-actions { display: flex; flex-wrap: wrap; gap: .35rem; justify-content: flex-end; }
|
||||||
|
.header-warning-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: .45rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
.header-warning-list,
|
||||||
|
.header-action-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
.header-warning-list { justify-content: flex-start; min-width: 0; }
|
||||||
|
.header-action-list { justify-content: flex-end; flex: none; }
|
||||||
|
.header-entries {
|
||||||
|
margin: .25rem 0 .25rem;
|
||||||
|
padding: .25rem .35rem;
|
||||||
|
border: 1px solid rgba(59,167,160,.28);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(17,19,21,.72);
|
||||||
|
}
|
||||||
|
.header-entries .table-wrap { overflow-x: auto; overflow-y: visible; }
|
||||||
|
.compact-entries-table { font-size: .74rem; }
|
||||||
|
.compact-entries-table th,
|
||||||
|
.compact-entries-table td { padding: .22rem .32rem; white-space: nowrap; line-height: 1.05; }
|
||||||
|
.compact-entries-table .pill { padding: .1rem .38rem; font-size: .68rem; }
|
||||||
|
.compact-entries-table .entry-actions { gap: .25rem; }
|
||||||
|
.compact-entries-table button.small { padding: .22rem .38rem; font-size: .68rem; }
|
||||||
|
.header-warning {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.45rem;
|
||||||
|
padding: .22rem .45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(196,106,58,.18);
|
||||||
|
color: var(--brand-dark);
|
||||||
|
border: 1px solid rgba(196,106,58,.55);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.header-warning.bad { background: rgba(139,30,30,.35); color: #ffb4b4; border-color: rgba(179,38,38,.7); }
|
||||||
|
.header-action-button {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 1.45rem;
|
||||||
|
padding: .22rem .45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #111315;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.header-action-button:hover { background: var(--accent); color: #111315; border-color: var(--accent); transform: none; }
|
||||||
|
.planner-column-header {
|
||||||
|
margin-top: .25rem;
|
||||||
|
border-top: 1px solid rgba(59,167,160,.25);
|
||||||
|
padding-top: .2rem;
|
||||||
|
}
|
||||||
|
.planner-header-table th { border-bottom: 0; }
|
||||||
|
.planner-header-table th,
|
||||||
|
.planner-body-table td { padding: .55rem .45rem; }
|
||||||
|
.panel.planner-panel {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 0 clamp(.45rem, 2vw, 1.25rem) 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: .35rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card, .panel {
|
||||||
|
background: rgba(27,30,34,.92);
|
||||||
|
border: 1px solid rgba(59,167,160,.35);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card { padding: .36rem .45rem; }
|
||||||
|
.card .label { color: var(--muted); font-size: .64rem; line-height: 1.05; }
|
||||||
|
.card .value { font-size: 1rem; font-weight: 800; margin-top: .02rem; }
|
||||||
|
.card .hint { color: var(--muted); font-size: .6rem; margin-top: .02rem; line-height: 1.05; }
|
||||||
|
.card.bad .value { color: var(--bad); }
|
||||||
|
.card.warn .value { color: var(--warn); }
|
||||||
|
.card.good .value { color: var(--good); }
|
||||||
|
|
||||||
|
.grid { display: grid; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.two-col { grid-template-columns: minmax(0, 1.1fr) minmax(22rem, .9fr); }
|
||||||
|
.panel { padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.panel-heading p { color: var(--muted); margin-bottom: 0; font-size: .9rem; }
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: .8rem;
|
||||||
|
}
|
||||||
|
.form-grid.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: .35rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .88rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .7rem .8rem;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #111315;
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: 3px solid rgba(196,106,58,.22); border-color: var(--brand); }
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .55rem;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.checkbox-row input { width: auto; margin-top: .2rem; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand);
|
||||||
|
color: #fff;
|
||||||
|
padding: .55rem .8rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .12s ease, background .12s ease;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--brand-dark); transform: translateY(-1px); }
|
||||||
|
button.ghost {
|
||||||
|
background: #111315;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
button.ghost:hover { background: var(--accent); color: #111315; }
|
||||||
|
button.small { padding: .36rem .55rem; font-size: .78rem; }
|
||||||
|
button.danger { background: #8B1E1E; color: var(--ink); border: 1px solid #8B1E1E; }
|
||||||
|
.actions { display: flex; justify-content: flex-end; gap: .6rem; }
|
||||||
|
.help-text { color: var(--muted); margin: 0; font-size: .9rem; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.quick-flex-form {
|
||||||
|
display: flex;
|
||||||
|
gap: .3rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: .15rem;
|
||||||
|
}
|
||||||
|
.quick-flex-form input { min-width: 0; font-size: 1rem; font-weight: 800; padding: .36rem .42rem; }
|
||||||
|
.quick-flex-form button { flex: none; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(46rem, calc(100vw - 2rem));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, .7);
|
||||||
|
}
|
||||||
|
.modal.wide { width: min(64rem, calc(100vw - 2rem)); }
|
||||||
|
.modal::backdrop { background: rgba(11, 13, 15, .78); backdrop-filter: blur(4px); }
|
||||||
|
.modal-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
||||||
|
.month-table { table-layout: fixed; min-width: 760px; }
|
||||||
|
.month-table th:nth-child(1), .month-table td:nth-child(1) { width: 8.5rem; }
|
||||||
|
.month-table th:nth-child(2), .month-table td:nth-child(2) { width: auto; }
|
||||||
|
.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(5), .month-table td:nth-child(5),
|
||||||
|
.month-table th:nth-child(6), .month-table td:nth-child(6),
|
||||||
|
.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 { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
tbody tr:hover { background: rgba(255,255,255,.04); }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.strong-num { font-weight: 900; color: var(--ink); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.bad-text { color: var(--bad); font-weight: 800; }
|
||||||
|
.warn-text { color: var(--warn); font-weight: 800; }
|
||||||
|
.good-text { color: var(--good); font-weight: 800; }
|
||||||
|
|
||||||
|
.entry-actions { display: flex; gap: .4rem; justify-content: flex-end; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #26383A;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.pill.approved, .pill.confirmed { background: #1f3b32; color: var(--ink); }
|
||||||
|
.pill.unpaid { background: rgba(196,106,58,.22); color: var(--brand-dark); }
|
||||||
|
.important-col { background: rgba(59,167,160,.12); font-size: 1rem; }
|
||||||
|
.correction-cell { min-width: 5.2rem; }
|
||||||
|
.correction-input {
|
||||||
|
width: 4.8rem;
|
||||||
|
padding: .28rem .35rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings { display: grid; gap: .55rem; }
|
||||||
|
.warning {
|
||||||
|
border-left: 4px solid var(--warn);
|
||||||
|
background: rgba(196,106,58,.16);
|
||||||
|
padding: .65rem .75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.warning.bad { border-left-color: var(--bad); background: rgba(139,30,30,.32); }
|
||||||
|
.warning.good { border-left-color: var(--good); background: rgba(31,59,50,.75); }
|
||||||
|
|
||||||
|
.years { display: grid; gap: 1.25rem; }
|
||||||
|
.year-block h3 { margin: 0 0 .65rem; }
|
||||||
|
.month-table td.entries { min-width: 15rem; }
|
||||||
|
.month-entry {
|
||||||
|
display: block;
|
||||||
|
margin: .15rem 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.month-entry small { color: var(--muted); }
|
||||||
|
.entry-date { font-size: .68rem; }
|
||||||
|
.planner-entry-meta {
|
||||||
|
display: inline;
|
||||||
|
margin-left: .35rem;
|
||||||
|
font-size: .72rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
.planner-entry-dates {
|
||||||
|
display: block;
|
||||||
|
font-size: .66rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: .8rem 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.two-col { grid-template-columns: 1fr; }
|
||||||
|
.form-grid.compact { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.floating-header { padding: .3rem .4rem; }
|
||||||
|
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .28rem; }
|
||||||
|
.card { padding: .34rem .4rem; }
|
||||||
|
.card .label { font-size: .6rem; }
|
||||||
|
.card .value { font-size: .95rem; }
|
||||||
|
.card .hint { display: none; }
|
||||||
|
.quick-flex-form { margin-top: .06rem; }
|
||||||
|
.quick-flex-form input { padding: .26rem .32rem; font-size: .9rem; }
|
||||||
|
.quick-flex-form button { padding: .26rem .38rem; }
|
||||||
|
.header-entries { padding: .18rem .25rem; margin: .18rem 0; }
|
||||||
|
.header-entries .table-wrap { overflow-x: auto; overflow-y: visible; }
|
||||||
|
.compact-entries-table { font-size: .68rem; }
|
||||||
|
.compact-entries-table th, .compact-entries-table td { padding: .18rem .25rem; }
|
||||||
|
.header-warning-row { gap: .3rem; }
|
||||||
|
.header-warning, .header-action-button { min-height: 1.32rem; font-size: .68rem; padding: .18rem .36rem; }
|
||||||
|
main { padding: 0 .4rem 3rem; }
|
||||||
|
.planner-column-header { margin-top: .18rem; padding-top: .16rem; }
|
||||||
|
.planner-header-table th,
|
||||||
|
.planner-body-table td { padding: .42rem .32rem; }
|
||||||
|
.planner-header-table th { font-size: .68rem; }
|
||||||
|
.month-table { min-width: 680px; }
|
||||||
|
.form-grid, .form-grid.compact { grid-template-columns: 1fr; }
|
||||||
|
th, td { padding: .55rem .45rem; }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue