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.
1176 lines
14 KiB
JavaScript
1176 lines
14 KiB
JavaScript
/* ============================================================
|
||
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'
|
||
);
|
||
|
||
}
|