399 lines
14 KiB
PHP
399 lines
14 KiB
PHP
<?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),
|
|
'feriefridage_overrides' => get_feriefridage_overrides($db),
|
|
'flex_overrides' => get_flex_overrides($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), $payload['value'] ?? null);
|
|
respond(['ok' => true, 'corrections' => get_corrections($db)]);
|
|
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':
|
|
$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,
|
|
non_working_days REAL 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
|
|
)");
|
|
|
|
$columns = array_column($db->query('PRAGMA table_info(entries)')->fetchAll(), 'name');
|
|
if (!in_array('non_working_days', $columns, true)) {
|
|
$db->exec('ALTER TABLE entries ADD COLUMN non_working_days REAL NOT NULL DEFAULT 0');
|
|
}
|
|
|
|
$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)
|
|
)");
|
|
|
|
$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 = [
|
|
'planning_start_year' => '2026',
|
|
'planning_start_month' => '1',
|
|
'planning_years' => '3',
|
|
'planning_months' => '36',
|
|
'previous_months' => '5',
|
|
'future_months' => '30',
|
|
'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',
|
|
'previous_months',
|
|
'future_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, mixed $value): void
|
|
{
|
|
if ($year < 1900 || $year > 2200 || $month < 1 || $month > 12) {
|
|
fail('Invalid correction year/month', 400);
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
$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' => (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
|
|
{
|
|
return $db->query('SELECT id, title, start_date, end_date, source, status, allow_unpaid, non_working_days, 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;
|
|
$nonWorkingDays = (float)($entry['non_working_days'] ?? 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 ($nonWorkingDays < 0 || fmod($nonWorkingDays * 2, 1.0) !== 0.0) {
|
|
fail('Additional non-working days must be in steps of 0.5', 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,
|
|
':non_working_days' => $nonWorkingDays,
|
|
':note' => $note,
|
|
];
|
|
|
|
if ($id === null) {
|
|
$stmt = $db->prepare('INSERT INTO entries
|
|
(title, start_date, end_date, source, status, allow_unpaid, non_working_days, note)
|
|
VALUES (:title, :start_date, :end_date, :source, :status, :allow_unpaid, :non_working_days, :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,
|
|
non_working_days = :non_working_days,
|
|
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;
|
|
}
|