Files
eatme/gscript/ClosureService.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

2048 lines
26 KiB
JavaScript
Raw 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.

function getMyMonthClosure(token, period) {
const user =
requireUser_(
token,
[
'EMPLOYEE',
'MANAGER',
'ADMIN'
]
);
if (
!user.employee_id
) {
throw new Error(
'Účet není propojen se zaměstnancem.'
);
}
period =
String(
period ||
Utilities.formatDate(
new Date(),
CFG.TZ,
'yyyy-MM'
)
).trim();
if (
!/^\d{4}-\d{2}$/.test(
period
)
) {
throw new Error(
'Neplatné období.'
);
}
/*
* Načteme uzávěrky jako TEXT.
* Žádné Date objekty posílané do browseru.
*/
let closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
let closure =
closures.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
user.employee_id
) &&
String(
row.period
) ===
period
);
}
);
/*
* Pokud ještě uzávěrka neexistuje,
* automaticky ji založíme.
*/
if (
!closure
) {
const closureId =
uuid_(
'CLOSE'
);
append_(
CFG.SHEETS.MONTH_CLOSURES,
{
closure_id:
closureId,
employee_id:
user.employee_id,
period:
period,
employee_confirmed_at:
'',
manager_approved_at:
'',
manager_approved_by:
'',
locked_at:
'',
status:
'WAITING_EMPLOYEE'
}
);
/*
* Znovu načteme už jako text.
*/
closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
closure =
closures.find(
function(row) {
return (
String(
row.closure_id
) ===
String(
closureId
)
);
}
);
}
if (
!closure
) {
throw new Error(
'Nepodařilo se vytvořit měsíční uzávěrku.'
);
}
const summary =
getClosureSummary_(
user.employee_id,
period
);
/*
* Do browseru pouze primitivní typy.
*/
return {
closure_id:
String(
closure.closure_id ||
''
),
employee_id:
String(
closure.employee_id ||
''
),
period:
String(
closure.period ||
period
),
status:
String(
closure.status ||
'WAITING_EMPLOYEE'
),
employee_confirmed_at:
String(
closure.employee_confirmed_at ||
''
),
manager_approved_at:
String(
closure.manager_approved_at ||
''
),
locked_at:
String(
closure.locked_at ||
''
),
summary: {
worked_minutes:
Number(
summary.worked_minutes ||
0
),
shift_count:
Number(
summary.shift_count ||
0
),
earned_estimate:
Number(
summary.earned_estimate ||
0
),
night_minutes:
Number(
summary.night_minutes ||
0
),
weekend_minutes:
Number(
summary.weekend_minutes ||
0
)
}
};
}
/* ============================================================
POTVRZENÍ DOCHÁZKY ZAMĚSTNANCEM
============================================================ */
function confirmMyMonthClosure(
token,
period
) {
const user =
requireUser_(
token,
[
'EMPLOYEE',
'MANAGER',
'ADMIN'
]
);
if (
!user.employee_id
) {
throw new Error(
'Účet není propojen se zaměstnancem.'
);
}
period =
String(
period || ''
).trim();
if (
!/^\d{4}-\d{2}$/.test(
period
)
) {
throw new Error(
'Neplatné období.'
);
}
const lock =
LockService
.getScriptLock();
lock.waitLock(
10000
);
try {
/*
* Nejdřív si uzávěrku vyžádáme.
* Tím zajistíme, že existuje.
*/
const closureData =
getMyMonthClosure(
token,
period
);
const closureId =
closureData.closure_id;
const currentStatus =
String(
closureData.status ||
''
);
if (
currentStatus ===
'LOCKED'
) {
throw new Error(
'Docházka je už uzamčena.'
);
}
if (
currentStatus ===
'MANAGER_APPROVED'
) {
throw new Error(
'Docházka už byla schválena vedoucím.'
);
}
if (
currentStatus ===
'EMPLOYEE_CONFIRMED'
) {
return {
ok:true
};
}
/*
* Kontrola otevřené směny v daném měsíci.
*/
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const openShift =
shifts.find(
function(shift) {
if (
String(
shift.employee_id
) !==
String(
user.employee_id
)
) {
return false;
}
if (
String(
shift.status
) !==
'OPEN'
) {
return false;
}
const date =
parseEmployeeDate_(
shift.clock_in
);
if (
!date
) {
return false;
}
return (
Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM'
) ===
period
);
}
);
if (
openShift
) {
throw new Error(
'V tomto měsíci máš stále otevřenou směnu.'
);
}
/*
* Nevyřešené žádosti o opravu.
*/
const requests =
displayRows_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS
);
const pendingRequest =
requests.find(
function(request) {
return (
String(
request.employee_id
) ===
String(
user.employee_id
) &&
String(
request.status
) ===
'PENDING'
);
}
);
if (
pendingRequest
) {
throw new Error(
'Nejdřív je potřeba vyřešit žádost o opravu docházky.'
);
}
updateBy_(
CFG.SHEETS.MONTH_CLOSURES,
'closure_id',
closureId,
{
employee_confirmed_at:
now_(),
status:
'EMPLOYEE_CONFIRMED'
}
);
setMonthShiftStatus_(
user.employee_id,
period,
'EMPLOYEE_CONFIRMED'
);
audit_(
user.user_id,
'MONTH_CONFIRMED',
'MONTH_CLOSURE',
closureId,
currentStatus,
'EMPLOYEE_CONFIRMED'
);
notifyManagers_(
user.employee_id,
'Docházka čeká na schválení',
'Zaměstnanec potvrdil docházku za ' +
period +
'.'
);
return {
ok:true
};
}
finally {
lock.releaseLock();
}
}
/* ============================================================
SOUHRN MĚSÍCE
============================================================ */
function getClosureSummary_(
employeeId,
period
) {
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const rates =
displayRows_(
CFG.SHEETS.PAY_RATES
);
let workedMinutes =
0;
let shiftCount =
0;
let earnedEstimate =
0;
let nightMinutes =
0;
let weekendMinutes =
0;
shifts.forEach(
function(shift) {
if (
String(
shift.employee_id
) !==
String(
employeeId
)
) {
return;
}
const date =
parseEmployeeDate_(
shift.clock_in
);
if (
!date
) {
return;
}
const shiftPeriod =
Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM'
);
if (
shiftPeriod !==
period
) {
return;
}
/*
* Otevřenou směnu nepočítáme.
*/
if (
String(
shift.status
) ===
'OPEN'
) {
return;
}
const minutes =
numberFromSheet_(
shift.worked_minutes
);
const night =
numberFromSheet_(
shift.night_minutes
);
const weekend =
numberFromSheet_(
shift.weekend_minutes
);
workedMinutes +=
minutes;
nightMinutes +=
night;
weekendMinutes +=
weekend;
if (
minutes >
0
) {
shiftCount++;
}
const hourlyRate =
employeeRateAt_(
rates,
employeeId,
date
);
earnedEstimate +=
(
minutes /
60
) *
hourlyRate;
}
);
return {
worked_minutes:
Math.round(
workedMinutes
),
shift_count:
shiftCount,
earned_estimate:
Math.round(
earnedEstimate
),
night_minutes:
Math.round(
nightMinutes
),
weekend_minutes:
Math.round(
weekendMinutes
)
};
}
/* ============================================================
ŽÁDOST O OPRAVU SMĚNY
============================================================ */
function createAttendanceCorrectionRequest(
token,
shiftId,
requestedClockIn,
requestedClockOut,
reason
) {
const user =
requireUser_(
token,
[
'EMPLOYEE',
'MANAGER',
'ADMIN'
]
);
if (
!user.employee_id
) {
throw new Error(
'Účet není propojen se zaměstnancem.'
);
}
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const shift =
shifts.find(
function(row) {
return (
String(
row.shift_id
) ===
String(
shiftId
) &&
String(
row.employee_id
) ===
String(
user.employee_id
)
);
}
);
if (
!shift
) {
throw new Error(
'Směna nebyla nalezena.'
);
}
if (
String(
shift.status
) ===
'LOCKED'
) {
throw new Error(
'Uzamčenou směnu už nelze upravit.'
);
}
reason =
String(
reason ||
''
).trim();
if (
reason.length <
3
) {
throw new Error(
'Doplň důvod opravy.'
);
}
const requestId =
uuid_(
'FIX'
);
append_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS,
{
request_id:
requestId,
shift_id:
shift.shift_id,
employee_id:
user.employee_id,
field:
'SHIFT_TIME',
old_value:
JSON.stringify({
clock_in:
shift.clock_in,
clock_out:
shift.clock_out
}),
requested_value:
JSON.stringify({
clock_in:
requestedClockIn ||
'',
clock_out:
requestedClockOut ||
''
}),
reason:
reason,
status:
'PENDING',
created_at:
now_(),
resolved_at:
'',
resolved_by:
''
}
);
audit_(
user.user_id,
'ATTENDANCE_CORRECTION_REQUESTED',
'SHIFT',
shift.shift_id,
'',
requestId
);
notifyManagers_(
user.employee_id,
'Nová žádost o opravu docházky',
'Zaměstnanec požádal o opravu směny.'
);
return {
ok:true
};
}
/* ============================================================
ADMIN ČEKAJÍCÍ UZÁVĚRKY
============================================================ */
function getPendingClosures(token) {
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
const employees =
displayRows_(
CFG.SHEETS.EMPLOYEES
);
const employeeMap =
{};
employees.forEach(
function(employee) {
employeeMap[
String(
employee.employee_id
)
] =
employee;
}
);
return closures
.filter(
function(closure) {
return [
'EMPLOYEE_CONFIRMED',
'MANAGER_APPROVED'
].includes(
String(
closure.status
)
);
}
)
.map(
function(closure) {
const employee =
employeeMap[
String(
closure.employee_id
)
];
const summary =
getClosureSummary_(
closure.employee_id,
closure.period
);
return {
closure_id:
String(
closure.closure_id ||
''
),
employee_id:
String(
closure.employee_id ||
''
),
employee_name:
employee
? (
String(
employee.first_name ||
''
) +
' ' +
String(
employee.last_name ||
''
)
).trim()
: String(
closure.employee_id ||
''
),
period:
String(
closure.period ||
''
),
status:
String(
closure.status ||
''
),
worked_minutes:
Number(
summary.worked_minutes ||
0
),
shift_count:
Number(
summary.shift_count ||
0
),
earned_estimate:
Number(
summary.earned_estimate ||
0
)
};
}
);
}
/* ============================================================
ADMIN / MANAGER SCHVÁLENÍ
============================================================ */
function managerApproveClosure(
token,
closureId
) {
const user =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
const closure =
closures.find(
function(row) {
return (
String(
row.closure_id
) ===
String(
closureId
)
);
}
);
if (
!closure
) {
throw new Error(
'Uzávěrka nebyla nalezena.'
);
}
if (
String(
closure.status
) !==
'EMPLOYEE_CONFIRMED'
) {
throw new Error(
'Docházku zatím nepotvrdil zaměstnanec.'
);
}
const requests =
displayRows_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS
);
const pending =
requests.find(
function(request) {
return (
String(
request.employee_id
) ===
String(
closure.employee_id
) &&
String(
request.status
) ===
'PENDING'
);
}
);
if (
pending
) {
throw new Error(
'Zaměstnanec má nevyřešenou žádost o opravu docházky.'
);
}
updateBy_(
CFG.SHEETS.MONTH_CLOSURES,
'closure_id',
closureId,
{
manager_approved_at:
now_(),
manager_approved_by:
user.user_id,
status:
'MANAGER_APPROVED'
}
);
setMonthShiftStatus_(
closure.employee_id,
closure.period,
'MANAGER_APPROVED'
);
audit_(
user.user_id,
'MONTH_APPROVED',
'MONTH_CLOSURE',
closureId,
'EMPLOYEE_CONFIRMED',
'MANAGER_APPROVED'
);
notifyEmployee_(
closure.employee_id,
'Docházka schválena',
'Docházka za ' +
closure.period +
' byla schválena.'
);
return {
ok:true
};
}
/* ============================================================
ADMIN UZAMČENÍ
============================================================ */
function lockMonthClosure(
token,
closureId
) {
const user =
requireUser_(
token,
[
'ADMIN'
]
);
const closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
const closure =
closures.find(
function(row) {
return (
String(
row.closure_id
) ===
String(
closureId
)
);
}
);
if (
!closure
) {
throw new Error(
'Uzávěrka nebyla nalezena.'
);
}
if (
String(
closure.status
) !==
'MANAGER_APPROVED'
) {
throw new Error(
'Uzávěrku lze zamknout až po schválení.'
);
}
updateBy_(
CFG.SHEETS.MONTH_CLOSURES,
'closure_id',
closureId,
{
locked_at:
now_(),
status:
'LOCKED'
}
);
setMonthShiftStatus_(
closure.employee_id,
closure.period,
'LOCKED'
);
createPayrollDraft_(
closure.employee_id,
closure.period
);
audit_(
user.user_id,
'MONTH_LOCKED',
'MONTH_CLOSURE',
closureId,
'MANAGER_APPROVED',
'LOCKED'
);
notifyEmployee_(
closure.employee_id,
'Docházka uzavřena',
'Docházka za ' +
closure.period +
' byla uzavřena a předána ke zpracování.'
);
return {
ok:true
};
}
/* ============================================================
PŘEPIS STAVU SMĚN V MĚSÍCI
============================================================ */
function setMonthShiftStatus_(
employeeId,
period,
newStatus
) {
const sheet =
sh_(
CFG.SHEETS.SHIFTS
);
const range =
sheet.getDataRange();
const values =
range.getValues();
if (
values.length <
2
) {
return;
}
const headers =
values[0]
.map(
function(value) {
return String(
value
);
}
);
const employeeIndex =
headers.indexOf(
'employee_id'
);
const clockInIndex =
headers.indexOf(
'clock_in'
);
const statusIndex =
headers.indexOf(
'status'
);
const updatedIndex =
headers.indexOf(
'updated_at'
);
if (
employeeIndex <
0 ||
clockInIndex <
0 ||
statusIndex <
0
) {
throw new Error(
'Tabulka SHIFTS nemá potřebné sloupce.'
);
}
for (
let i = 1;
i < values.length;
i++
) {
const row =
values[i];
if (
String(
row[
employeeIndex
]
) !==
String(
employeeId
)
) {
continue;
}
const date =
parseEmployeeDate_(
row[
clockInIndex
]
);
if (
!date
) {
continue;
}
const rowPeriod =
Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM'
);
if (
rowPeriod !==
period
) {
continue;
}
/*
* Otevřenou směnu nesmíme
* přepsat na potvrzenou.
*/
if (
String(
row[
statusIndex
]
) ===
'OPEN'
) {
continue;
}
sheet
.getRange(
i + 1,
statusIndex + 1
)
.setValue(
newStatus
);
if (
updatedIndex >=
0
) {
sheet
.getRange(
i + 1,
updatedIndex + 1
)
.setValue(
now_()
);
}
}
}
/* ============================================================
PAYROLL DRAFT
============================================================ */
function createPayrollDraft_(
employeeId,
period
) {
const payrolls =
displayRows_(
CFG.SHEETS.PAYROLL
);
const existing =
payrolls.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
employeeId
) &&
String(
row.period
) ===
String(
period
)
);
}
);
const summary =
getClosureSummary_(
employeeId,
period
);
if (
existing
) {
updateBy_(
CFG.SHEETS.PAYROLL,
'payroll_id',
existing.payroll_id,
{
approved_minutes:
summary.worked_minutes,
base_amount:
summary.earned_estimate,
final_amount:
summary.earned_estimate,
status:
'READY_FOR_ACCOUNTANT',
updated_at:
now_()
}
);
return;
}
append_(
CFG.SHEETS.PAYROLL,
{
payroll_id:
uuid_(
'PAY'
),
employee_id:
employeeId,
period:
period,
approved_minutes:
summary.worked_minutes,
base_amount:
summary.earned_estimate,
bonus_amount:
0,
tips_amount:
0,
other_amount:
0,
final_amount:
summary.earned_estimate,
status:
'READY_FOR_ACCOUNTANT',
payment_date:
'',
updated_at:
now_()
}
);
}
/* ============================================================
NOTIFIKACE ZAMĚSTNANCI
============================================================ */
function notifyEmployee_(
employeeId,
title,
message
) {
const users =
displayRows_(
CFG.SHEETS.USERS
);
const user =
users.find(
function(row) {
return (
String(
row.employee_id
) ===
String(
employeeId
)
);
}
);
if (
!user
) {
return;
}
append_(
CFG.SHEETS.NOTIFICATIONS,
{
notification_id:
uuid_(
'NOT'
),
user_id:
user.user_id,
type:
'ATTENDANCE',
title:
title,
message:
message,
url:
'',
read_at:
'',
created_at:
now_(),
email_sent_at:
''
}
);
if (
user.email
) {
try {
MailApp.sendEmail({
to:
user.email,
subject:
CFG.APP_NAME +
' ' +
title,
body:
message,
name:
CFG.APP_NAME
});
}
catch(error) {
/*
* E-mail nesmí shodit
* samotnou uzávěrku.
*/
Logger.log(
'EMAIL ERROR: ' +
error
);
}
}
}
/* ============================================================
NOTIFIKACE ADMINŮM / MANAGERŮM
============================================================ */
function notifyManagers_(
employeeId,
title,
message
) {
const users =
displayRows_(
CFG.SHEETS.USERS
);
users
.filter(
function(user) {
return [
'ADMIN',
'MANAGER'
].includes(
String(
user.role
)
);
}
)
.forEach(
function(user) {
append_(
CFG.SHEETS.NOTIFICATIONS,
{
notification_id:
uuid_(
'NOT'
),
user_id:
user.user_id,
type:
'ATTENDANCE',
title:
title,
message:
message,
url:
'',
read_at:
'',
created_at:
now_(),
email_sent_at:
''
}
);
}
);
}
/* ============================================================
DIAGNOSTICKÝ TEST
Tuhle funkci můžeš ručně spustit.
============================================================ */
function testClosureDatabase() {
Logger.log(
'MONTH_CLOSURES: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
)
)
);
Logger.log(
'SHIFTS: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.SHIFTS
)
)
);
Logger.log(
'PAY_RATES: ' +
JSON.stringify(
displayRows_(
CFG.SHEETS.PAY_RATES
)
)
);
Logger.log(
'ClosureService OK'
);
}
function testCreateClosureForEmployee() {
const employeeId =
'EMP_6C233E6102D94CE4';
const period =
'2026-08';
let closures =
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
);
let closure =
closures.find(
function(row) {
return (
String(
row.employee_id
) ===
employeeId &&
String(
row.period
) ===
period
);
}
);
if (
!closure
) {
append_(
CFG.SHEETS.MONTH_CLOSURES,
{
closure_id:
uuid_(
'CLOSE'
),
employee_id:
employeeId,
period:
period,
employee_confirmed_at:
'',
manager_approved_at:
'',
manager_approved_by:
'',
locked_at:
'',
status:
'WAITING_EMPLOYEE'
}
);
}
Logger.log(
JSON.stringify(
displayRows_(
CFG.SHEETS.MONTH_CLOSURES
)
)
);
}