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

1592 lines
19 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.

/* ============================================================
EATME PORTÁL SHIFT RECONCILIATION SERVICE
Porovnává:
- plánované směny: SHIFT_SLOTS + SHIFT_SIGNUPS
- skutečnou docházku: SHIFTS
Nic nepřepisuje. Jde o read-only analytickou vrstvu.
============================================================ */
const SHIFT_RECONCILIATION = Object.freeze({
LATE_TOLERANCE_MINUTES: 10,
EARLY_LEAVE_TOLERANCE_MINUTES: 10,
MATCH_WINDOW_HOURS: 8
});
/* ============================================================
ZAMĚSTNANEC PLÁN VS DOCHÁZKA
============================================================ */
function getMyShiftReconciliation(
token,
period
) {
const user =
requireUser_(
token,
[
'EMPLOYEE',
'MANAGER',
'ADMIN'
]
);
if (
!user.employee_id
) {
throw new Error(
'Účet není propojen se zaměstnancem.'
);
}
period =
normalizeReconciliationPeriod_(
period
);
const context =
buildReconciliationContext_();
const rows =
reconcileEmployeePeriod_(
user.employee_id,
period,
context
);
return {
period:
period,
stats:
reconciliationStats_(
rows
),
rows:
rows
};
}
/* ============================================================
ADMIN / MANAGER PLÁN VS DOCHÁZKA
============================================================ */
function getAdminShiftReconciliation(
token,
period
) {
requireUser_(
token,
[
'ADMIN',
'MANAGER'
]
);
period =
normalizeReconciliationPeriod_(
period
);
const context =
buildReconciliationContext_();
const result =
[];
context.employees
.filter(
function(employee) {
return (
String(
employee.active
).toUpperCase() ===
'TRUE'
);
}
)
.forEach(
function(employee) {
const rows =
reconcileEmployeePeriod_(
employee.employee_id,
period,
context
);
const employeeName =
(
String(
employee.first_name ||
''
) +
' ' +
String(
employee.last_name ||
''
)
).trim();
rows.forEach(
function(row) {
row.employee_id =
String(
employee.employee_id ||
''
);
row.employee_name =
employeeName;
result.push(
row
);
}
);
}
);
result.sort(
function(a,b) {
if (
a.date !==
b.date
) {
return String(
a.date
).localeCompare(
String(
b.date
)
);
}
if (
a.planned_start !==
b.planned_start
) {
return String(
a.planned_start ||
a.actual_start ||
''
).localeCompare(
String(
b.planned_start ||
b.actual_start ||
''
)
);
}
return String(
a.employee_name ||
''
).localeCompare(
String(
b.employee_name ||
''
),
'cs'
);
}
);
return {
period:
period,
stats:
reconciliationStats_(
result
),
rows:
result
};
}
/* ============================================================
KONTEXT KAŽDÝ LIST NAČTEME JEN JEDNOU
============================================================ */
function buildReconciliationContext_() {
const slots =
displayRows_(
'SHIFT_SLOTS'
);
const signups =
displayRows_(
'SHIFT_SIGNUPS'
);
const shifts =
displayRows_(
CFG.SHEETS.SHIFTS
);
const employees =
displayRows_(
CFG.SHEETS.EMPLOYEES
);
const slotMap =
{};
slots.forEach(
function(slot) {
slotMap[
String(
slot.slot_id
)
] =
slot;
}
);
const plannedByEmployee =
{};
signups.forEach(
function(signup) {
if (
String(
signup.status
) !==
'APPROVED'
) {
return;
}
const slot =
slotMap[
String(
signup.slot_id
)
];
if (
!slot
) {
return;
}
const employeeId =
String(
signup.employee_id ||
''
);
if (
!plannedByEmployee[
employeeId
]
) {
plannedByEmployee[
employeeId
] =
[];
}
plannedByEmployee[
employeeId
].push(
{
signup:
signup,
slot:
slot
}
);
}
);
const actualByEmployee =
{};
shifts.forEach(
function(shift) {
const employeeId =
String(
shift.employee_id ||
''
);
if (
!actualByEmployee[
employeeId
]
) {
actualByEmployee[
employeeId
] =
[];
}
actualByEmployee[
employeeId
].push(
shift
);
}
);
return {
employees:
employees,
plannedByEmployee:
plannedByEmployee,
actualByEmployee:
actualByEmployee
};
}
/* ============================================================
PÁROVÁNÍ JEDNOHO ZAMĚSTNANCE
============================================================ */
function reconcileEmployeePeriod_(
employeeId,
period,
context
) {
const planned =
(
context.plannedByEmployee[
String(
employeeId
)
] ||
[]
)
.filter(
function(item) {
return (
String(
item.slot.date ||
''
).substring(
0,
7
) ===
period
);
}
)
.map(
function(item) {
return makePlannedItem_(
item
);
}
)
.filter(
Boolean
)
.sort(
function(a,b) {
return (
a.start.getTime() -
b.start.getTime()
);
}
);
const actual =
(
context.actualByEmployee[
String(
employeeId
)
] ||
[]
)
.map(
function(shift) {
return makeActualItem_(
shift
);
}
)
.filter(
function(item) {
if (
!item ||
!item.start
) {
return false;
}
return (
Utilities.formatDate(
item.start,
CFG.TZ,
'yyyy-MM'
) ===
period
);
}
)
.sort(
function(a,b) {
return (
a.start.getTime() -
b.start.getTime()
);
}
);
const usedActual =
{};
const rows =
[];
planned.forEach(
function(plan) {
let bestIndex =
-1;
let bestDistance =
Infinity;
actual.forEach(
function(attendance,index) {
if (
usedActual[
index
]
) {
return;
}
const distance =
Math.abs(
attendance.start.getTime() -
plan.start.getTime()
);
if (
distance >
SHIFT_RECONCILIATION.MATCH_WINDOW_HOURS *
3600000
) {
return;
}
/*
* Přednost má stejný pracovní den.
*/
const planDate =
Utilities.formatDate(
plan.start,
CFG.TZ,
'yyyy-MM-dd'
);
const actualDate =
Utilities.formatDate(
attendance.start,
CFG.TZ,
'yyyy-MM-dd'
);
const penalty =
planDate ===
actualDate
? 0
: 24 *
3600000;
const score =
distance +
penalty;
if (
score <
bestDistance
) {
bestDistance =
score;
bestIndex =
index;
}
}
);
if (
bestIndex >=
0
) {
usedActual[
bestIndex
] =
true;
rows.push(
reconciliationRow_(
plan,
actual[
bestIndex
]
)
);
} else {
rows.push(
reconciliationRow_(
plan,
null
)
);
}
}
);
actual.forEach(
function(attendance,index) {
if (
usedActual[
index
]
) {
return;
}
rows.push(
reconciliationRow_(
null,
attendance
)
);
}
);
return rows.sort(
function(a,b) {
const aKey =
String(
a.date ||
''
) +
' ' +
String(
a.planned_start ||
a.actual_start ||
''
);
const bKey =
String(
b.date ||
''
) +
' ' +
String(
b.planned_start ||
b.actual_start ||
''
);
return aKey.localeCompare(
bKey
);
}
);
}
/* ============================================================
PLÁNOVANÁ SMĚNA
============================================================ */
function makePlannedItem_(item) {
const slot =
item.slot;
const date =
String(
slot.date ||
''
).substring(
0,
10
);
if (
!date
) {
return null;
}
const start =
reconciliationDateTime_(
date,
slot.start_time
);
let end =
reconciliationDateTime_(
date,
slot.end_time
);
if (
!start ||
!end
) {
return null;
}
if (
end <=
start
) {
end =
new Date(
end.getTime() +
86400000
);
}
return {
slot_id:
String(
slot.slot_id ||
''
),
date:
date,
start:
start,
end:
end,
start_time:
reconciliationHHMM_(
start
),
end_time:
reconciliationHHMM_(
end
)
};
}
/* ============================================================
SKUTEČNÁ SMĚNA
============================================================ */
function makeActualItem_(shift) {
const start =
reconciliationParseDate_(
shift.clock_in
);
if (
!start
) {
return null;
}
const end =
reconciliationParseDate_(
shift.clock_out
);
return {
shift_id:
String(
shift.shift_id ||
''
),
start:
start,
end:
end,
status:
String(
shift.status ||
''
),
worked_minutes:
reconciliationNumber_(
shift.worked_minutes
)
};
}
/* ============================================================
VÝSLEDNÝ ŘÁDEK
============================================================ */
function reconciliationRow_(
plan,
attendance
) {
const now =
new Date();
let status =
'OK';
let label =
'V pořádku';
let lateMinutes =
0;
let earlyLeaveMinutes =
0;
let differenceMinutes =
0;
if (
plan &&
!attendance
) {
if (
now <
plan.end
) {
status =
'PLANNED';
label =
'Plánováno';
} else {
status =
'ABSENT';
label =
'Bez docházky';
}
}
if (
!plan &&
attendance
) {
status =
'EXTRA';
label =
'Směna navíc';
}
if (
plan &&
attendance
) {
lateMinutes =
Math.max(
0,
Math.round(
(
attendance.start.getTime() -
plan.start.getTime()
) /
60000
)
);
if (
attendance.end
) {
earlyLeaveMinutes =
Math.max(
0,
Math.round(
(
plan.end.getTime() -
attendance.end.getTime()
) /
60000
)
);
}
const plannedMinutes =
Math.round(
(
plan.end.getTime() -
plan.start.getTime()
) /
60000
);
differenceMinutes =
attendance.worked_minutes -
plannedMinutes;
if (
attendance.status ===
'OPEN'
) {
status =
'OPEN';
label =
'Právě probíhá';
} else if (
lateMinutes >
SHIFT_RECONCILIATION.LATE_TOLERANCE_MINUTES
) {
status =
'LATE';
label =
'Pozdní příchod';
} else if (
earlyLeaveMinutes >
SHIFT_RECONCILIATION.EARLY_LEAVE_TOLERANCE_MINUTES
) {
status =
'EARLY_LEAVE';
label =
'Předčasný odchod';
}
}
const date =
plan
? plan.date
: Utilities.formatDate(
attendance.start,
CFG.TZ,
'yyyy-MM-dd'
);
return {
date:
date,
slot_id:
plan
? plan.slot_id
: '',
shift_id:
attendance
? attendance.shift_id
: '',
planned_start:
plan
? plan.start_time
: '',
planned_end:
plan
? plan.end_time
: '',
actual_start:
attendance
? reconciliationHHMM_(
attendance.start
)
: '',
actual_end:
attendance &&
attendance.end
? reconciliationHHMM_(
attendance.end
)
: '',
worked_minutes:
attendance
? attendance.worked_minutes
: 0,
late_minutes:
lateMinutes,
early_leave_minutes:
earlyLeaveMinutes,
difference_minutes:
differenceMinutes,
status:
status,
status_label:
label
};
}
/* ============================================================
STATISTIKY
============================================================ */
function reconciliationStats_(rows) {
const stats =
{
total:
rows.length,
ok:
0,
late:
0,
early_leave:
0,
absent:
0,
extra:
0,
open:
0,
planned:
0
};
rows.forEach(
function(row) {
switch(
String(
row.status
)
) {
case 'OK':
stats.ok++;
break;
case 'LATE':
stats.late++;
break;
case 'EARLY_LEAVE':
stats.early_leave++;
break;
case 'ABSENT':
stats.absent++;
break;
case 'EXTRA':
stats.extra++;
break;
case 'OPEN':
stats.open++;
break;
case 'PLANNED':
stats.planned++;
break;
}
}
);
return stats;
}
/* ============================================================
HELPERS
============================================================ */
function normalizeReconciliationPeriod_(
period
) {
period =
String(
period ||
''
).trim();
if (
!/^\d{4}-\d{2}$/.test(
period
)
) {
return Utilities.formatDate(
new Date(),
CFG.TZ,
'yyyy-MM'
);
}
return period;
}
function reconciliationDateTime_(
date,
time
) {
const dateParts =
String(
date ||
''
).split(
'-'
);
const timeParts =
String(
time ||
'00:00'
).substring(
0,
5
).split(
':'
);
if (
dateParts.length !==
3 ||
timeParts.length <
2
) {
return null;
}
const result =
new Date(
Number(
dateParts[0]
),
Number(
dateParts[1]
) - 1,
Number(
dateParts[2]
),
Number(
timeParts[0]
),
Number(
timeParts[1]
),
0
);
return isNaN(
result.getTime()
)
? null
: result;
}
function reconciliationParseDate_(
value
) {
if (
!value
) {
return null;
}
if (
value instanceof Date
) {
return value;
}
const text =
String(
value
).trim();
let match;
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] ||
0
),
Number(
match[5] ||
0
),
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
)
);
}
match =
text.match(
/^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/
);
if (
match
) {
return new Date(
Number(
match[3]
),
Number(
match[2]
) - 1,
Number(
match[1]
),
Number(
match[4] ||
0
),
Number(
match[5] ||
0
),
Number(
match[6] ||
0
)
);
}
const date =
new Date(
text
);
return isNaN(
date.getTime()
)
? null
: date;
}
function reconciliationHHMM_(
date
) {
if (
!date
) {
return '';
}
return Utilities.formatDate(
date,
CFG.TZ,
'HH:mm'
);
}
function reconciliationNumber_(
value
) {
const number =
Number(
String(
value == null
? 0
: value
)
.replace(
',',
'.'
)
.replace(
/\s/g,
''
)
);
return isFinite(
number
)
? number
: 0;
}
/* ============================================================
DIAGNOSTIKA
============================================================ */
function testShiftReconciliation() {
const period =
Utilities.formatDate(
new Date(),
CFG.TZ,
'yyyy-MM'
);
const context =
buildReconciliationContext_();
Logger.log(
'Employees: ' +
context.employees.length
);
Logger.log(
'Period: ' +
period
);
Logger.log(
'Shift reconciliation OK'
);
}