Add shift planning, month closure + payroll, and a dark modern redesign

Ports two modules from the friend's Google Apps Script build (kept as
reference in gscript/) onto the TypeScript stack, rewritten cleanly against
this app's own data model rather than copied 1:1:

- Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots),
  lazy idempotent generation per period (no cron needed), employee
  signup/cancel with collision + capacity checks, admin calendar view with
  slot editing and manual assignment.
- Month closure + payroll: employee confirms the month (blocked while a
  shift is still open), admin locks and finalizes pay (base hours * rate +
  tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates
  are versioned by date, defaulting the first-ever rate to apply
  retroactively to the employee's whole history.
- A shift left open more than 12h (forgotten clock-out) is auto-closed at
  clock_in + 12h, checked lazily on read instead of a background job.
- Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with
  an amber accent, keeping every existing class name so no component
  logic needed to change.

Backend test coverage (jest) for all three workflows: shift planning,
closure/payroll, and the forgotten-clock-out auto-close.
This commit is contained in:
Michal Pemcak
2026-08-16 18:27:50 +02:00
parent fffcb73ea4
commit f917ed06a8
64 changed files with 46554 additions and 553 deletions

72
gscript/Database.js Normal file
View File

@@ -0,0 +1,72 @@
function db_() {
const id = PropertiesService.getScriptProperties().getProperty('DB_SPREADSHEET_ID');
if (!id) throw new Error('Databáze není inicializovaná. Spusť setupSystem().');
return SpreadsheetApp.openById(id);
}
function sh_(name) {
const s = db_().getSheetByName(name);
if (!s) throw new Error('Chybí list ' + name);
return s;
}
function rows_(name) {
const s = sh_(name);
const values = s.getDataRange().getValues();
if (values.length < 2) return [];
const headers = values[0].map(String);
return values.slice(1).filter(r => r.some(v => v !== '')).map(r => {
const o = {};
headers.forEach((h, i) => o[h] = r[i]);
return o;
});
}
function append_(name, obj) {
const s = sh_(name);
const headers = s.getRange(1,1,1,s.getLastColumn()).getValues()[0];
s.appendRow(headers.map(h => obj[h] !== undefined ? obj[h] : ''));
return obj;
}
function updateBy_(name, key, value, patch) {
const s = sh_(name);
const data = s.getDataRange().getValues();
if (!data.length) return false;
const headers = data[0].map(String);
const keyCol = headers.indexOf(key);
if (keyCol < 0) throw new Error('Sloupec ' + key + ' neexistuje v ' + name);
const row = data.slice(1).findIndex(r => String(r[keyCol]) === String(value));
if (row < 0) return false;
Object.keys(patch).forEach(k => {
const c = headers.indexOf(k);
if (c >= 0) s.getRange(row + 2, c + 1).setValue(patch[k]);
});
return true;
}
function findOne_(name, predicate) {
return rows_(name).find(predicate) || null;
}
function uuid_(prefix) {
return (prefix || 'ID') + '_' + Utilities.getUuid().replace(/-/g,'').slice(0,16).toUpperCase();
}
function now_() { return new Date(); }
function isoDate_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM-dd');
}
function period_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM');
}
function emailNorm_(email) {
return String(email || '').trim().toLowerCase();
}
function json_(v) {
try { return JSON.stringify(v); } catch(e) { return String(v); }
}