Files
eatme/gscript/EmployeeEditService.js
Michal Pemcak f917ed06a8 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.
2026-08-16 18:41:11 +02:00

1176 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
MESTAFF EMPLOYEE EDIT SERVICE
Bez zásahu do existujícího EmployeeService.gs.
============================================================ */
function getEmployeeDetail(
token,
employeeId
) {
const currentUser =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const employees =
displayRows_(
CFG.SHEETS.EMPLOYEES
);
const employee =
employees.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
employeeId
)
);
}
);
if (
!employee
) {
throw new Error(
'Zaměstnanec nebyl nalezen.'
);
}
const users =
displayRows_(
CFG.SHEETS.USERS
);
const employeeUser =
users.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
employeeId
)
);
}
);
if (
currentUser.role !==
'ADMIN' &&
employeeUser &&
employeeUser.role ===
'ADMIN'
) {
throw new Error(
'Vedoucí nemůže upravovat administrátora.'
);
}
const rates =
displayRows_(
CFG.SHEETS.PAY_RATES
);
const currentRate =
employeeEditCurrentRate_(
rates,
employeeId,
new Date()
);
return {
employee_id:
String(
employee.employee_id ||
''
),
first_name:
String(
employee.first_name ||
''
),
last_name:
String(
employee.last_name ||
''
),
email:
String(
employee.email ||
(
employeeUser
? employeeUser.email
: ''
) ||
''
),
phone:
String(
employee.phone ||
''
),
company_id:
String(
employee.company_id ||
''
),
location_id:
String(
employee.location_id ||
''
),
position:
String(
employee.position ||
''
),
employment_type:
String(
employee.employment_type ||
''
),
terminal_pin:
String(
employee.terminal_pin ||
''
),
start_date:
employeeEditDateOnly_(
employee.start_date
),
end_date:
employeeEditDateOnly_(
employee.end_date
),
active:
employeeEditBool_(
employee.active
),
role:
employeeUser
? String(
employeeUser.role ||
'EMPLOYEE'
)
: 'EMPLOYEE',
hourly_rate:
currentRate
? numberFromSheet_(
currentRate.hourly_rate
)
: 0
};
}
function updateEmployee(
token,
data
) {
const currentUser =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
data =
data ||
{};
const employeeId =
String(
data.employee_id ||
''
).trim();
if (
!employeeId
) {
throw new Error(
'Chybí employee_id.'
);
}
const firstName =
String(
data.first_name ||
''
).trim();
const lastName =
String(
data.last_name ||
''
).trim();
const email =
String(
data.email ||
''
)
.trim()
.toLowerCase();
const role =
String(
data.role ||
'EMPLOYEE'
)
.trim()
.toUpperCase();
if (
!firstName ||
!lastName
) {
throw new Error(
'Jméno a příjmení jsou povinné.'
);
}
if (
!email ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
email
)
) {
throw new Error(
'Zadej platný e-mail.'
);
}
if (
![
'EMPLOYEE',
'MANAGER',
'ACCOUNTANT',
'ADMIN'
].includes(
role
)
) {
throw new Error(
'Neplatná role.'
);
}
if (
currentUser.role !==
'ADMIN' &&
role ===
'ADMIN'
) {
throw new Error(
'Pouze administrátor může přidělit roli ADMIN.'
);
}
const employees =
displayRows_(
CFG.SHEETS.EMPLOYEES
);
const employee =
employees.find(
function(row) {
return (
String(
row.employee_id
) ===
employeeId
);
}
);
if (
!employee
) {
throw new Error(
'Zaměstnanec nebyl nalezen.'
);
}
const users =
displayRows_(
CFG.SHEETS.USERS
);
const employeeUser =
users.find(
function(row) {
return (
String(
row.employee_id
) ===
employeeId
);
}
);
if (
currentUser.role !==
'ADMIN' &&
employeeUser &&
String(
employeeUser.role
) ===
'ADMIN'
) {
throw new Error(
'Vedoucí nemůže upravovat administrátora.'
);
}
/*
* Kontrola e-mailové duplicity.
*/
const duplicateEmployee =
employees.find(
function(row) {
return (
String(
row.employee_id
) !==
employeeId &&
String(
row.email ||
''
)
.trim()
.toLowerCase() ===
email
);
}
);
const duplicateUser =
users.find(
function(row) {
return (
String(
row.employee_id ||
''
) !==
employeeId &&
String(
row.email ||
''
)
.trim()
.toLowerCase() ===
email
);
}
);
if (
duplicateEmployee ||
duplicateUser
) {
throw new Error(
'Tento e-mail už používá jiný účet.'
);
}
const pin =
String(
data.terminal_pin ||
''
).trim();
if (
pin &&
!/^\d{4,6}$/.test(
pin
)
) {
throw new Error(
'PIN musí obsahovat 4 až 6 číslic.'
);
}
if (
pin
) {
const duplicatePin =
employees.find(
function(row) {
return (
String(
row.employee_id
) !==
employeeId &&
employeeEditBool_(
row.active
) &&
String(
row.terminal_pin ||
''
).trim() ===
pin
);
}
);
if (
duplicatePin
) {
throw new Error(
'Tento PIN už používá jiný aktivní zaměstnanec.'
);
}
}
const active =
data.active === true ||
String(
data.active
).toUpperCase() ===
'TRUE';
/*
* Základní data zaměstnance.
*/
updateBy_(
CFG.SHEETS.EMPLOYEES,
'employee_id',
employeeId,
{
first_name:
firstName,
last_name:
lastName,
email:
email,
phone:
String(
data.phone ||
''
).trim(),
company_id:
String(
data.company_id ||
''
).trim(),
location_id:
String(
data.location_id ||
''
).trim(),
position:
String(
data.position ||
''
).trim(),
employment_type:
String(
data.employment_type ||
''
).trim(),
terminal_pin:
pin,
start_date:
String(
data.start_date ||
''
).trim(),
end_date:
String(
data.end_date ||
''
).trim(),
active:
active
}
);
/*
* Synchronizace přihlašovacího účtu.
*/
if (
employeeUser
) {
updateBy_(
CFG.SHEETS.USERS,
'user_id',
employeeUser.user_id,
{
email:
email,
role:
role,
active:
active
}
);
}
/*
* Historická změna hodinové sazby.
*/
let rateChanged =
false;
const newRate =
employeeEditNumber_(
data.hourly_rate
);
const validFrom =
String(
data.rate_valid_from ||
Utilities.formatDate(
new Date(),
CFG.TZ,
'yyyy-MM-dd'
)
).trim();
if (
!/^\d{4}-\d{2}-\d{2}$/.test(
validFrom
)
) {
throw new Error(
'Neplatné datum účinnosti nové sazby.'
);
}
const rates =
displayRows_(
CFG.SHEETS.PAY_RATES
);
const currentRate =
employeeEditCurrentRate_(
rates,
employeeId,
employeeEditParseDate_(
validFrom
)
);
const currentRateAmount =
currentRate
? numberFromSheet_(
currentRate.hourly_rate
)
: 0;
if (
Math.abs(
newRate -
currentRateAmount
) >
0.0001
) {
/*
* Zavřeme předchozí sazbu den před účinností nové.
*/
if (
currentRate &&
currentRate.pay_rate_id
) {
const previousDay =
employeeEditAddDays_(
validFrom,
-1
);
const currentValidFrom =
employeeEditDateOnly_(
currentRate.valid_from
);
if (
!currentValidFrom ||
currentValidFrom <
validFrom
) {
updateBy_(
CFG.SHEETS.PAY_RATES,
'pay_rate_id',
currentRate.pay_rate_id,
{
valid_to:
previousDay
}
);
}
}
append_(
CFG.SHEETS.PAY_RATES,
{
pay_rate_id:
uuid_(
'RATE'
),
employee_id:
employeeId,
valid_from:
validFrom,
valid_to:
'',
hourly_rate:
newRate,
created_by:
currentUser.user_id,
created_at:
now_()
}
);
rateChanged =
true;
}
audit_(
currentUser.user_id,
'EMPLOYEE_UPDATED',
'EMPLOYEE',
employeeId,
'',
JSON.stringify({
email:
email,
role:
role,
active:
active,
hourly_rate:
newRate,
rate_changed:
rateChanged
})
);
return {
ok:true,
rate_changed:
rateChanged
};
}
/* ============================================================
HELPERS
============================================================ */
function employeeEditBool_(
value
) {
if (
value ===
true
) {
return true;
}
return [
'TRUE',
'ANO',
'YES',
'1'
].includes(
String(
value ||
''
)
.trim()
.toUpperCase()
);
}
function employeeEditNumber_(
value
) {
const number =
Number(
String(
value == null
? 0
: value
)
.trim()
.replace(
',',
'.'
)
);
if (
!isFinite(
number
) ||
number <
0
) {
throw new Error(
'Hodinová sazba musí být nezáporné číslo.'
);
}
return Math.round(
number *
100
) /
100;
}
function employeeEditParseDate_(
value
) {
const match =
String(
value ||
''
)
.trim()
.match(
/^(\d{4})-(\d{2})-(\d{2})$/
);
if (
!match
) {
return null;
}
return new Date(
Number(
match[1]
),
Number(
match[2]
) -
1,
Number(
match[3]
)
);
}
function employeeEditDateOnly_(
value
) {
if (
!value
) {
return '';
}
if (
/^\d{4}-\d{2}-\d{2}$/.test(
String(
value
).trim()
)
) {
return String(
value
).trim();
}
const date =
typeof parseEmployeeDate_ ===
'function'
? parseEmployeeDate_(
value
)
: new Date(
value
);
if (
!date ||
isNaN(
date.getTime()
)
) {
return '';
}
return Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM-dd'
);
}
function employeeEditCurrentRate_(
rates,
employeeId,
targetDate
) {
if (
!targetDate
) {
return null;
}
const target =
Utilities.formatDate(
targetDate,
CFG.TZ,
'yyyy-MM-dd'
);
const valid =
rates
.filter(
function(rate) {
if (
String(
rate.employee_id
) !==
String(
employeeId
)
) {
return false;
}
const from =
employeeEditDateOnly_(
rate.valid_from
) ||
'0000-00-00';
const to =
employeeEditDateOnly_(
rate.valid_to
) ||
'9999-12-31';
return (
from <=
target &&
to >=
target
);
}
)
.sort(
function(a,b) {
return employeeEditDateOnly_(
b.valid_from
).localeCompare(
employeeEditDateOnly_(
a.valid_from
)
);
}
);
return valid.length
? valid[0]
: null;
}
function employeeEditAddDays_(
yyyyMmDd,
days
) {
const date =
employeeEditParseDate_(
yyyyMmDd
);
if (
!date
) {
return '';
}
date.setDate(
date.getDate() +
Number(
days ||
0
)
);
return Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM-dd'
);
}
/* ============================================================
DIAGNOSTIKA
============================================================ */
function testEmployeeEditService() {
Logger.log(
'EMPLOYEES: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.EMPLOYEES
)
)
);
Logger.log(
'USERS: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.USERS
)
)
);
Logger.log(
'PAY_RATES: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.PAY_RATES
)
)
);
Logger.log(
'EmployeeEditService OK'
);
}