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

1345 lines
18 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.

/* ============================================================
EATME PORTÁL ATTENDANCE CORRECTION ADMIN SERVICE
Admin / manager:
- seznam čekajících žádostí o opravu docházky
- schválení -> přepis SHIFTS + přepočet minut
- zamítnutí -> pouze změna stavu žádosti
- uzamčenou směnu nelze měnit
============================================================ */
/* ============================================================
SEZNAM ŽÁDOSTÍ
============================================================ */
function getPendingAttendanceCorrections(token) {
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const requests =
displayRows_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS
);
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const employees =
displayRows_(
CFG.SHEETS.EMPLOYEES
);
const shiftMap =
{};
shifts.forEach(
function(shift) {
shiftMap[
String(
shift.shift_id ||
''
)
] =
shift;
}
);
const employeeMap =
{};
employees.forEach(
function(employee) {
employeeMap[
String(
employee.employee_id ||
''
)
] =
employee;
}
);
/*
* Jedna oprava může být v SHIFT_CHANGE_REQUESTS uložena
* jako více řádků (např. clock_in + clock_out).
* Seskládáme je podle request_id.
*/
const groups =
{};
requests.forEach(
function(request,index) {
if (
String(
request.status ||
''
).trim() !==
'PENDING'
) {
return;
}
const groupId =
String(
request.request_id ||
''
) ||
(
'ROW_' +
index
);
if (
!groups[
groupId
]
) {
groups[
groupId
] =
{
request_id:
groupId,
shift_id:
String(
request.shift_id ||
''
),
employee_id:
String(
request.employee_id ||
''
),
reason:
String(
request.reason ||
''
),
created_at:
String(
request.created_at ||
''
),
fields:
[],
rows:
[]
};
}
groups[
groupId
].rows.push(
request
);
groups[
groupId
].fields.push(
{
field:
String(
request.field ||
''
),
old_value:
String(
request.old_value ||
''
),
requested_value:
String(
request.requested_value ||
''
)
}
);
if (
!groups[
groupId
].reason &&
request.reason
) {
groups[
groupId
].reason =
String(
request.reason
);
}
}
);
const result =
Object.keys(
groups
)
.map(
function(groupId) {
const group =
groups[
groupId
];
const shift =
shiftMap[
String(
group.shift_id
)
] ||
null;
const employeeId =
group.employee_id ||
(
shift
? String(
shift.employee_id ||
''
)
: ''
);
const employee =
employeeMap[
employeeId
] ||
null;
const requested =
attendanceCorrectionRequestedValues_(
group.fields
);
return {
request_id:
group.request_id,
shift_id:
group.shift_id,
employee_id:
employeeId,
employee_name:
employee
? (
String(
employee.first_name ||
''
) +
' ' +
String(
employee.last_name ||
''
)
).trim()
: employeeId,
work_date:
shift
? String(
shift.work_date ||
''
)
: '',
current_clock_in:
shift
? String(
shift.clock_in ||
''
)
: '',
current_clock_out:
shift
? String(
shift.clock_out ||
''
)
: '',
requested_clock_in:
requested.clock_in,
requested_clock_out:
requested.clock_out,
reason:
group.reason,
created_at:
group.created_at,
shift_status:
shift
? String(
shift.status ||
''
)
: '',
locked:
shift
? String(
shift.status ||
''
).toUpperCase() ===
'LOCKED'
: false
};
}
)
.sort(
function(a,b) {
return String(
b.created_at ||
''
).localeCompare(
String(
a.created_at ||
''
)
);
}
);
return {
count:
result.length,
rows:
result
};
}
/* ============================================================
SCHVÁLENÍ ŽÁDOSTI
============================================================ */
function approveAttendanceCorrection(
token,
requestId
) {
const user =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const lock =
LockService
.getScriptLock();
lock.waitLock(
10000
);
try {
const requests =
displayRows_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS
);
const requestRows =
requests.filter(
function(request) {
return (
String(
request.request_id ||
''
) ===
String(
requestId
) &&
String(
request.status ||
''
).trim() ===
'PENDING'
);
}
);
if (
!requestRows.length
) {
throw new Error(
'Žádost nebyla nalezena nebo už byla vyřešena.'
);
}
const shiftId =
String(
requestRows[0].shift_id ||
''
);
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const shift =
shifts.find(
function(row) {
return (
String(
row.shift_id ||
''
) ===
shiftId
);
}
);
if (
!shift
) {
throw new Error(
'Směna uvedená v žádosti nebyla nalezena.'
);
}
if (
String(
shift.status ||
''
).toUpperCase() ===
'LOCKED'
) {
throw new Error(
'Tato směna je už uzamčená a předaná do mzdy. Nejdřív je nutné řešit mzdovou uzávěrku.'
);
}
const requested =
attendanceCorrectionRequestedValues_(
requestRows.map(
function(request) {
return {
field:
request.field,
old_value:
request.old_value,
requested_value:
request.requested_value
};
}
)
);
let newClockIn =
requested.clock_in ||
String(
shift.clock_in ||
''
);
let newClockOut =
requested.clock_out ||
String(
shift.clock_out ||
''
);
const clockInDate =
parseAttendanceCorrectionDate_(
newClockIn
);
const clockOutDate =
parseAttendanceCorrectionDate_(
newClockOut
);
if (
!clockInDate
) {
throw new Error(
'Navrhovaný čas příchodu není platný.'
);
}
if (
newClockOut &&
!clockOutDate
) {
throw new Error(
'Navrhovaný čas odchodu není platný.'
);
}
if (
clockOutDate &&
clockOutDate <=
clockInDate
) {
throw new Error(
'Odchod musí být později než příchod.'
);
}
const breakMinutes =
correctionNumber_(
shift.break_minutes
);
let workedMinutes =
correctionNumber_(
shift.worked_minutes
);
let nightMinutes =
correctionNumber_(
shift.night_minutes
);
if (
clockOutDate
) {
workedMinutes =
Math.max(
0,
Math.round(
(
clockOutDate.getTime() -
clockInDate.getTime()
) /
60000
) -
breakMinutes
);
nightMinutes =
calculateCorrectionNightMinutes_(
clockInDate,
clockOutDate
);
}
const workDate =
Utilities.formatDate(
clockInDate,
CFG.TZ,
'yyyy-MM-dd'
);
const update =
{
clock_in:
formatAttendanceCorrectionDate_(
clockInDate
),
work_date:
workDate,
worked_minutes:
workedMinutes,
night_minutes:
nightMinutes,
updated_at:
now_()
};
if (
clockOutDate
) {
update.clock_out =
formatAttendanceCorrectionDate_(
clockOutDate
);
/*
* Pokud měla směna odchod a není uzamčená,
* má být po opravě uzavřená.
*/
if (
String(
shift.status ||
''
) ===
'OPEN'
) {
update.status =
'COMPLETED';
}
}
updateBy_(
CFG.SHEETS.SHIFTS,
'shift_id',
shiftId,
update
);
requestRows.forEach(
function(request) {
updateBy_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS,
'request_id',
request.request_id,
{
status:
'APPROVED',
resolved_at:
now_(),
resolved_by:
user.user_id
}
);
}
);
audit_(
user.user_id,
'ATTENDANCE_CORRECTION_APPROVED',
'SHIFT',
shiftId,
{
clock_in:
shift.clock_in,
clock_out:
shift.clock_out,
worked_minutes:
shift.worked_minutes
},
{
clock_in:
update.clock_in,
clock_out:
update.clock_out ||
shift.clock_out,
worked_minutes:
workedMinutes
}
);
notifyEmployee_(
shift.employee_id,
'Oprava docházky schválena',
'Tvoje žádost o opravu docházky ze dne ' +
workDate +
' byla schválena.'
);
return {
ok:
true,
shift_id:
shiftId,
worked_minutes:
workedMinutes
};
}
finally {
lock.releaseLock();
}
}
/* ============================================================
ZAMÍTNUTÍ ŽÁDOSTI
============================================================ */
function rejectAttendanceCorrection(
token,
requestId
) {
const user =
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
const requests =
displayRows_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS
);
const requestRows =
requests.filter(
function(request) {
return (
String(
request.request_id ||
''
) ===
String(
requestId
) &&
String(
request.status ||
''
).trim() ===
'PENDING'
);
}
);
if (
!requestRows.length
) {
throw new Error(
'Žádost nebyla nalezena nebo už byla vyřešena.'
);
}
requestRows.forEach(
function(request) {
updateBy_(
CFG.SHEETS.SHIFT_CHANGE_REQUESTS,
'request_id',
request.request_id,
{
status:
'REJECTED',
resolved_at:
now_(),
resolved_by:
user.user_id
}
);
}
);
const shiftId =
String(
requestRows[0].shift_id ||
''
);
const employeeId =
String(
requestRows[0].employee_id ||
''
);
audit_(
user.user_id,
'ATTENDANCE_CORRECTION_REJECTED',
'SHIFT',
shiftId,
'',
''
);
if (
employeeId
) {
notifyEmployee_(
employeeId,
'Oprava docházky zamítnuta',
'Tvoje žádost o opravu docházky byla zamítnuta.'
);
}
return {
ok:true
};
}
/* ============================================================
REQUESTED VALUES ROBUSTNÍ PARSER
============================================================ */
function attendanceCorrectionRequestedValues_(
fields
) {
const result =
{
clock_in:
'',
clock_out:
''
};
(
fields ||
[]
).forEach(
function(item) {
const field =
String(
item.field ||
''
)
.trim()
.toLowerCase();
const value =
String(
item.requested_value ||
''
).trim();
if (
field ===
'clock_in'
) {
result.clock_in =
value;
return;
}
if (
field ===
'clock_out'
) {
result.clock_out =
value;
return;
}
/*
* Pro jistotu podporujeme i případ, že requested_value
* obsahuje JSON s oběma časy.
*/
if (
value &&
(
value.charAt(
0
) ===
'{'
)
) {
try {
const parsed =
JSON.parse(
value
);
if (
parsed.clock_in
) {
result.clock_in =
String(
parsed.clock_in
);
}
if (
parsed.clock_out
) {
result.clock_out =
String(
parsed.clock_out
);
}
}
catch(error) {
/*
* Není JSON, ignorujeme.
*/
}
}
}
);
return result;
}
/* ============================================================
DATUM / ČAS
============================================================ */
function parseAttendanceCorrectionDate_(
value
) {
if (
!value
) {
return null;
}
if (
value instanceof Date
) {
return value;
}
const text =
String(
value
).trim();
let match =
text.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2})(?::(\d{2}))?$/
);
if (
match
) {
return new Date(
Number(
match[1]
),
Number(
match[2]
) - 1,
Number(
match[3]
),
Number(
match[4]
),
Number(
match[5]
),
Number(
match[6] ||
0
)
);
}
match =
text.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/
);
if (
match
) {
return new Date(
Number(
match[1]
),
Number(
match[2]
) - 1,
Number(
match[3]
),
Number(
match[4]
),
Number(
match[5]
),
Number(
match[6] ||
0
)
);
}
match =
text.match(
/^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
);
if (
match
) {
return new Date(
Number(
match[3]
),
Number(
match[1]
) - 1,
Number(
match[2]
),
Number(
match[4] ||
0
),
Number(
match[5] ||
0
),
Number(
match[6] ||
0
)
);
}
const date =
new Date(
text
);
return isNaN(
date.getTime()
)
? null
: date;
}
function formatAttendanceCorrectionDate_(
date
) {
return Utilities.formatDate(
date,
CFG.TZ,
'yyyy-MM-dd HH:mm:ss'
);
}
/* ============================================================
NOČNÍ MINUTY 22:0006:00
============================================================ */
function calculateCorrectionNightMinutes_(
start,
end
) {
if (
!start ||
!end ||
end <=
start
) {
return 0;
}
let total =
0;
let cursor =
new Date(
start
);
cursor.setSeconds(
0,
0
);
while (
cursor <
end
) {
const hour =
cursor.getHours();
if (
hour >=
22 ||
hour <
6
) {
total++;
}
cursor =
new Date(
cursor.getTime() +
60000
);
}
return total;
}
/* ============================================================
ČÍSLO
============================================================ */
function correctionNumber_(
value
) {
const number =
Number(
String(
value == null
? 0
: value
)
.replace(
',',
'.'
)
.replace(
/\s/g,
''
)
);
return isFinite(
number
)
? number
: 0;
}