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

377
gscript/Security.js Normal file
View File

@@ -0,0 +1,377 @@
/* ============================================================
EATME PORTÁL SECURITY HELPERS
============================================================ */
/* ============================================================
NÁHODNÝ AKTIVAČNÍ KÓD
============================================================ */
function randomCode_() {
return String(
Math.floor(
100000 +
Math.random() *
900000
)
);
}
/* ============================================================
SESSION TOKEN
============================================================ */
function randomToken_() {
return Utilities
.base64EncodeWebSafe(
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
Utilities.getUuid() +
':' +
new Date().getTime() +
':' +
Math.random()
)
)
.replace(
/=+$/,
''
);
}
/* ============================================================
SHA-256
============================================================ */
function hashText_(text) {
const bytes =
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(
text
)
);
return bytes
.map(
function(byte) {
return (
'0' +
(
(
byte < 0
? byte + 256
: byte
)
.toString(
16
)
)
).slice(
-2
);
}
)
.join(
''
);
}
/* ============================================================
PASSWORD HASH
POZOR:
Toto je původní algoritmus.
Neměníme jej, aby fungovala současná hesla.
============================================================ */
function passwordHash_(
password,
salt
) {
const pepper =
PropertiesService
.getScriptProperties()
.getProperty(
'PASSWORD_PEPPER'
) ||
'';
let value =
String(
password
) +
'|' +
salt +
'|' +
pepper;
for (
let i = 0;
i < CFG.PASSWORD_ROUNDS;
i++
) {
value =
hashText_(
value +
'|' +
i
);
}
return value;
}
/* ============================================================
VALIDACE HESLA
============================================================ */
function validatePassword_(password) {
const value =
String(
password ||
''
);
if (
value.length <
10
) {
throw new Error(
'Heslo musí mít alespoň 10 znaků.'
);
}
if (
!/[A-Za-z]/.test(
value
) ||
!/[0-9]/.test(
value
)
) {
throw new Error(
'Heslo musí obsahovat písmeno a číslo.'
);
}
}
/* ============================================================
OVĚŘENÍ UŽIVATELE
Optimalizace:
už při každém requestu NEZAPISUJEME last_seen_at.
============================================================ */
function requireUser_(
token,
roles
) {
const tokenHash =
hashText_(
String(
token ||
''
)
);
const session =
findOne_(
CFG.SHEETS.SESSIONS,
function(row) {
return (
String(
row.token_hash
) ===
tokenHash &&
new Date(
row.expires_at
).getTime() >
Date.now()
);
}
);
if (
!session
) {
throw new Error(
'Přihlášení vypršelo. Přihlas se znovu.'
);
}
const user =
findOne_(
CFG.SHEETS.USERS,
function(row) {
return (
String(
row.user_id
) ===
String(
session.user_id
)
);
}
);
if (
!user ||
!(
user.active ===
true ||
String(
user.active
).toUpperCase() ===
'TRUE'
)
) {
throw new Error(
'Účet není aktivní.'
);
}
if (
roles &&
roles.length &&
!roles.includes(
String(
user.role
)
)
) {
throw new Error(
'Nemáš oprávnění.'
);
}
/*
* PŮVODNĚ ZDE BYLO:
*
* updateBy_(
* CFG.SHEETS.SESSIONS,
* 'session_id',
* session.session_id,
* {last_seen_at:now_()}
* );
*
* To způsobovalo zápis do Google Sheets při téměř
* každém kliknutí v aplikaci.
*/
return user;
}
/* ============================================================
AUDIT
============================================================ */
function audit_(
userId,
action,
entityType,
entityId,
oldValue,
newValue
) {
append_(
CFG.SHEETS.AUDIT_LOG,
{
audit_id:
uuid_(
'AUD'
),
user_id:
userId ||
'SYSTEM',
action:
action,
entity_type:
entityType ||
'',
entity_id:
entityId ||
'',
old_value:
json_(
oldValue
),
new_value:
json_(
newValue
),
created_at:
now_()
}
);
}