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.
2048 lines
26 KiB
JavaScript
2048 lines
26 KiB
JavaScript
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
|
||
)
|
||
)
|
||
);
|
||
|
||
} |