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.
1099 lines
15 KiB
JavaScript
1099 lines
15 KiB
JavaScript
function getDashboard(
|
|
token,
|
|
fromDate,
|
|
toDate
|
|
) {
|
|
|
|
const user =
|
|
requireUser_(
|
|
token
|
|
);
|
|
|
|
|
|
if (
|
|
!user.employee_id
|
|
) {
|
|
|
|
return getRoleDashboard_(
|
|
user
|
|
);
|
|
|
|
}
|
|
|
|
|
|
/* =========================================
|
|
ZAMĚSTNANCI
|
|
========================================= */
|
|
|
|
const employees =
|
|
displayRows_(
|
|
CFG.SHEETS.EMPLOYEES
|
|
);
|
|
|
|
|
|
const employee =
|
|
employees.find(
|
|
function(item) {
|
|
|
|
return (
|
|
String(
|
|
item.employee_id
|
|
) ===
|
|
String(
|
|
user.employee_id
|
|
)
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
if (
|
|
!employee
|
|
) {
|
|
|
|
throw new Error(
|
|
'Zaměstnanec nebyl nalezen.'
|
|
);
|
|
|
|
}
|
|
|
|
|
|
/* =========================================
|
|
DATUMOVÝ ROZSAH
|
|
========================================= */
|
|
|
|
const now =
|
|
new Date();
|
|
|
|
|
|
let from;
|
|
|
|
|
|
if (
|
|
fromDate
|
|
) {
|
|
|
|
from =
|
|
new Date(
|
|
fromDate +
|
|
'T00:00:00'
|
|
);
|
|
|
|
} else {
|
|
|
|
from =
|
|
new Date(
|
|
now.getFullYear(),
|
|
now.getMonth(),
|
|
1,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
|
|
}
|
|
|
|
|
|
let to;
|
|
|
|
|
|
if (
|
|
toDate
|
|
) {
|
|
|
|
to =
|
|
new Date(
|
|
toDate +
|
|
'T23:59:59'
|
|
);
|
|
|
|
} else {
|
|
|
|
to =
|
|
now;
|
|
|
|
}
|
|
|
|
|
|
/* =========================================
|
|
SMĚNY
|
|
========================================= */
|
|
|
|
const allShifts =
|
|
displayRows_(
|
|
CFG.SHEETS.SHIFTS
|
|
);
|
|
|
|
|
|
const employeeShifts =
|
|
allShifts
|
|
.filter(
|
|
function(shift) {
|
|
|
|
return (
|
|
String(
|
|
shift.employee_id
|
|
) ===
|
|
String(
|
|
employee.employee_id
|
|
)
|
|
);
|
|
|
|
}
|
|
)
|
|
.filter(
|
|
function(shift) {
|
|
|
|
const clockIn =
|
|
parseEmployeeDate_(
|
|
shift.clock_in
|
|
);
|
|
|
|
|
|
if (
|
|
!clockIn
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
clockIn >= from &&
|
|
clockIn <= to
|
|
);
|
|
|
|
}
|
|
)
|
|
.sort(
|
|
function(a,b) {
|
|
|
|
const aDate =
|
|
parseEmployeeDate_(
|
|
a.clock_in
|
|
);
|
|
|
|
|
|
const bDate =
|
|
parseEmployeeDate_(
|
|
b.clock_in
|
|
);
|
|
|
|
|
|
return (
|
|
(
|
|
bDate
|
|
? bDate.getTime()
|
|
: 0
|
|
)
|
|
-
|
|
(
|
|
aDate
|
|
? aDate.getTime()
|
|
: 0
|
|
)
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
/* =========================================
|
|
SAZBY
|
|
========================================= */
|
|
|
|
const payRates =
|
|
displayRows_(
|
|
CFG.SHEETS.PAY_RATES
|
|
);
|
|
|
|
|
|
/* =========================================
|
|
VÝPOČET HODIN + PENĚZ
|
|
========================================= */
|
|
|
|
let workedMinutes =
|
|
0;
|
|
|
|
|
|
let earnedEstimate =
|
|
0;
|
|
|
|
|
|
let shiftCount =
|
|
0;
|
|
|
|
|
|
employeeShifts.forEach(
|
|
function(shift) {
|
|
|
|
const status =
|
|
String(
|
|
shift.status || ''
|
|
).trim();
|
|
|
|
|
|
/*
|
|
* Do výdělku počítáme pouze
|
|
* dokončené směny.
|
|
*/
|
|
|
|
if (
|
|
![
|
|
'COMPLETED',
|
|
'EMPLOYEE_CONFIRMED',
|
|
'MANAGER_APPROVED',
|
|
'LOCKED'
|
|
].includes(
|
|
status
|
|
)
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
const minutes =
|
|
numberFromSheet_(
|
|
shift.worked_minutes
|
|
);
|
|
|
|
|
|
if (
|
|
minutes <= 0
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
const shiftDate =
|
|
parseEmployeeDate_(
|
|
shift.clock_in
|
|
);
|
|
|
|
|
|
const rate =
|
|
employeeRateAt_(
|
|
payRates,
|
|
employee.employee_id,
|
|
shiftDate
|
|
);
|
|
|
|
|
|
workedMinutes +=
|
|
minutes;
|
|
|
|
|
|
shiftCount++;
|
|
|
|
|
|
earnedEstimate +=
|
|
(
|
|
minutes /
|
|
60
|
|
) *
|
|
rate;
|
|
|
|
}
|
|
);
|
|
|
|
|
|
/* =========================================
|
|
OTEVŘENÁ SMĚNA
|
|
========================================= */
|
|
|
|
const openShift =
|
|
allShifts
|
|
.filter(
|
|
function(shift) {
|
|
|
|
return (
|
|
String(
|
|
shift.employee_id
|
|
) ===
|
|
String(
|
|
employee.employee_id
|
|
) &&
|
|
String(
|
|
shift.status
|
|
).trim() ===
|
|
'OPEN'
|
|
);
|
|
|
|
}
|
|
)
|
|
.sort(
|
|
function(a,b) {
|
|
|
|
const aDate =
|
|
parseEmployeeDate_(
|
|
a.clock_in
|
|
);
|
|
|
|
|
|
const bDate =
|
|
parseEmployeeDate_(
|
|
b.clock_in
|
|
);
|
|
|
|
|
|
return (
|
|
(
|
|
bDate
|
|
? bDate.getTime()
|
|
: 0
|
|
)
|
|
-
|
|
(
|
|
aDate
|
|
? aDate.getTime()
|
|
: 0
|
|
)
|
|
);
|
|
|
|
}
|
|
)[0] || null;
|
|
|
|
|
|
let currentShiftMinutes =
|
|
0;
|
|
|
|
|
|
if (
|
|
openShift
|
|
) {
|
|
|
|
const clockIn =
|
|
parseEmployeeDate_(
|
|
openShift.clock_in
|
|
);
|
|
|
|
|
|
if (
|
|
clockIn
|
|
) {
|
|
|
|
currentShiftMinutes =
|
|
Math.max(
|
|
0,
|
|
Math.floor(
|
|
(
|
|
Date.now() -
|
|
clockIn.getTime()
|
|
) /
|
|
60000
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/* =========================================
|
|
AKTUALITY
|
|
========================================= */
|
|
|
|
let news =
|
|
[];
|
|
|
|
|
|
try {
|
|
|
|
news =
|
|
getNewsForUser_(
|
|
user,
|
|
employee
|
|
);
|
|
|
|
|
|
/*
|
|
* Bezpečný převod pro frontend.
|
|
*/
|
|
|
|
news =
|
|
news
|
|
.slice(
|
|
0,
|
|
8
|
|
)
|
|
.map(
|
|
function(item) {
|
|
|
|
return {
|
|
|
|
news_id:
|
|
String(
|
|
item.news_id || ''
|
|
),
|
|
|
|
title:
|
|
String(
|
|
item.title || ''
|
|
),
|
|
|
|
content:
|
|
String(
|
|
item.content || ''
|
|
),
|
|
|
|
category:
|
|
String(
|
|
item.category || ''
|
|
),
|
|
|
|
require_confirmation:
|
|
item.require_confirmation === true ||
|
|
String(
|
|
item.require_confirmation
|
|
).toUpperCase() ===
|
|
'TRUE'
|
|
|
|
};
|
|
|
|
}
|
|
);
|
|
|
|
} catch(error) {
|
|
|
|
news =
|
|
[];
|
|
|
|
}
|
|
|
|
|
|
/* =========================================
|
|
BEZPEČNÝ SEZNAM SMĚN
|
|
========================================= */
|
|
|
|
const safeShifts =
|
|
employeeShifts
|
|
.slice(
|
|
0,
|
|
20
|
|
)
|
|
.map(
|
|
function(shift) {
|
|
|
|
const clockIn =
|
|
parseEmployeeDate_(
|
|
shift.clock_in
|
|
);
|
|
|
|
|
|
const clockOut =
|
|
parseEmployeeDate_(
|
|
shift.clock_out
|
|
);
|
|
|
|
|
|
return {
|
|
|
|
shift_id:
|
|
String(
|
|
shift.shift_id || ''
|
|
),
|
|
|
|
employee_id:
|
|
String(
|
|
shift.employee_id || ''
|
|
),
|
|
|
|
work_date:
|
|
String(
|
|
shift.work_date || ''
|
|
),
|
|
|
|
clock_in:
|
|
clockIn
|
|
? clockIn.toISOString()
|
|
: '',
|
|
|
|
clock_out:
|
|
clockOut
|
|
? clockOut.toISOString()
|
|
: '',
|
|
|
|
break_minutes:
|
|
numberFromSheet_(
|
|
shift.break_minutes
|
|
),
|
|
|
|
worked_minutes:
|
|
numberFromSheet_(
|
|
shift.worked_minutes
|
|
),
|
|
|
|
status:
|
|
String(
|
|
shift.status || ''
|
|
),
|
|
|
|
location_id:
|
|
String(
|
|
shift.location_id || ''
|
|
)
|
|
|
|
};
|
|
|
|
}
|
|
);
|
|
|
|
|
|
/* =========================================
|
|
DEBUG INFO
|
|
|
|
Zatím schválně vracíme i sazbu,
|
|
ať přesně vidíme, co systém používá.
|
|
========================================= */
|
|
|
|
const currentRate =
|
|
employeeRateAt_(
|
|
payRates,
|
|
employee.employee_id,
|
|
now
|
|
);
|
|
|
|
|
|
/* =========================================
|
|
VÝSLEDEK
|
|
========================================= */
|
|
|
|
return {
|
|
|
|
role:
|
|
String(
|
|
user.role || ''
|
|
),
|
|
|
|
|
|
employee: {
|
|
|
|
employee_id:
|
|
String(
|
|
employee.employee_id || ''
|
|
),
|
|
|
|
name:
|
|
(
|
|
String(
|
|
employee.first_name || ''
|
|
) +
|
|
' ' +
|
|
String(
|
|
employee.last_name || ''
|
|
)
|
|
).trim(),
|
|
|
|
position:
|
|
String(
|
|
employee.position || ''
|
|
)
|
|
|
|
},
|
|
|
|
|
|
stats: {
|
|
|
|
worked_minutes:
|
|
Math.round(
|
|
workedMinutes
|
|
),
|
|
|
|
earned_estimate:
|
|
Math.round(
|
|
earnedEstimate
|
|
),
|
|
|
|
shift_count:
|
|
shiftCount,
|
|
|
|
current_shift_minutes:
|
|
currentShiftMinutes,
|
|
|
|
hourly_rate:
|
|
currentRate
|
|
|
|
},
|
|
|
|
|
|
open_shift:
|
|
openShift
|
|
? {
|
|
|
|
shift_id:
|
|
String(
|
|
openShift.shift_id || ''
|
|
),
|
|
|
|
clock_in:
|
|
parseEmployeeDate_(
|
|
openShift.clock_in
|
|
)
|
|
? parseEmployeeDate_(
|
|
openShift.clock_in
|
|
).toISOString()
|
|
: '',
|
|
|
|
status:
|
|
'OPEN'
|
|
|
|
}
|
|
: null,
|
|
|
|
|
|
shifts:
|
|
safeShifts,
|
|
|
|
|
|
schedules:
|
|
[],
|
|
|
|
|
|
news:
|
|
news
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/* ============================================================
|
|
SAZBA PRO KONKRÉTNÍ DATUM
|
|
============================================================ */
|
|
|
|
function employeeRateAt_(
|
|
rates,
|
|
employeeId,
|
|
targetDate
|
|
) {
|
|
|
|
if (
|
|
!targetDate
|
|
) {
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
|
const target =
|
|
Utilities.formatDate(
|
|
targetDate,
|
|
CFG.TZ,
|
|
'yyyy-MM-dd'
|
|
);
|
|
|
|
|
|
const employeeRates =
|
|
rates
|
|
.filter(
|
|
function(rate) {
|
|
|
|
return (
|
|
String(
|
|
rate.employee_id
|
|
) ===
|
|
String(
|
|
employeeId
|
|
)
|
|
);
|
|
|
|
}
|
|
)
|
|
.filter(
|
|
function(rate) {
|
|
|
|
const fromDate =
|
|
parseEmployeeDate_(
|
|
rate.valid_from
|
|
);
|
|
|
|
|
|
const toDate =
|
|
parseEmployeeDate_(
|
|
rate.valid_to
|
|
);
|
|
|
|
|
|
const from =
|
|
fromDate
|
|
? Utilities.formatDate(
|
|
fromDate,
|
|
CFG.TZ,
|
|
'yyyy-MM-dd'
|
|
)
|
|
: '0000-00-00';
|
|
|
|
|
|
const to =
|
|
toDate
|
|
? Utilities.formatDate(
|
|
toDate,
|
|
CFG.TZ,
|
|
'yyyy-MM-dd'
|
|
)
|
|
: '9999-12-31';
|
|
|
|
|
|
return (
|
|
from <= target &&
|
|
to >= target
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
if (
|
|
!employeeRates.length
|
|
) {
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
|
employeeRates.sort(
|
|
function(a,b) {
|
|
|
|
const aDate =
|
|
parseEmployeeDate_(
|
|
a.valid_from
|
|
);
|
|
|
|
|
|
const bDate =
|
|
parseEmployeeDate_(
|
|
b.valid_from
|
|
);
|
|
|
|
|
|
return (
|
|
(
|
|
bDate
|
|
? bDate.getTime()
|
|
: 0
|
|
)
|
|
-
|
|
(
|
|
aDate
|
|
? aDate.getTime()
|
|
: 0
|
|
)
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
return numberFromSheet_(
|
|
employeeRates[0]
|
|
.hourly_rate
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/* ============================================================
|
|
DATUM Z GOOGLE SHEETS
|
|
============================================================ */
|
|
|
|
function parseEmployeeDate_(
|
|
value
|
|
) {
|
|
|
|
const text =
|
|
String(
|
|
value || ''
|
|
).trim();
|
|
|
|
|
|
if (
|
|
!text
|
|
) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
let match;
|
|
|
|
|
|
/*
|
|
* 2026-08-11
|
|
* 2026-08-11 23:45:00
|
|
*/
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* 11.8.2026 23:45:00
|
|
*/
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* 8/11/2026 23:45:00
|
|
*/
|
|
|
|
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
|
|
);
|
|
|
|
|
|
if (
|
|
isNaN(
|
|
date.getTime()
|
|
)
|
|
) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
return date;
|
|
|
|
}
|
|
|
|
|
|
|
|
/* ============================================================
|
|
ROLE DASHBOARD
|
|
============================================================ */
|
|
|
|
function getRoleDashboard_(
|
|
user
|
|
) {
|
|
|
|
return {
|
|
|
|
role:
|
|
String(
|
|
user.role || ''
|
|
)
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/* ============================================================
|
|
TEST VÝDĚLKU
|
|
|
|
Spustitelné přímo z editoru.
|
|
============================================================ */
|
|
|
|
function testEmployeeEarnings() {
|
|
|
|
const employees =
|
|
displayRows_(
|
|
CFG.SHEETS.EMPLOYEES
|
|
);
|
|
|
|
|
|
const shifts =
|
|
displayRows_(
|
|
CFG.SHEETS.SHIFTS
|
|
);
|
|
|
|
|
|
const rates =
|
|
displayRows_(
|
|
CFG.SHEETS.PAY_RATES
|
|
);
|
|
|
|
|
|
employees.forEach(
|
|
function(employee) {
|
|
|
|
const employeeId =
|
|
String(
|
|
employee.employee_id
|
|
);
|
|
|
|
|
|
const employeeShifts =
|
|
shifts.filter(
|
|
function(shift) {
|
|
|
|
return (
|
|
String(
|
|
shift.employee_id
|
|
) ===
|
|
employeeId
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
|
|
Logger.log(
|
|
'--------------------------------'
|
|
);
|
|
|
|
|
|
Logger.log(
|
|
'ZAMĚSTNANEC: ' +
|
|
employee.first_name +
|
|
' ' +
|
|
employee.last_name
|
|
);
|
|
|
|
|
|
employeeShifts.forEach(
|
|
function(shift) {
|
|
|
|
const date =
|
|
parseEmployeeDate_(
|
|
shift.clock_in
|
|
);
|
|
|
|
|
|
const rate =
|
|
employeeRateAt_(
|
|
rates,
|
|
employeeId,
|
|
date
|
|
);
|
|
|
|
|
|
const minutes =
|
|
numberFromSheet_(
|
|
shift.worked_minutes
|
|
);
|
|
|
|
|
|
Logger.log(
|
|
'SMĚNA: ' +
|
|
shift.shift_id +
|
|
' | stav=' +
|
|
shift.status +
|
|
' | minuty=' +
|
|
minutes +
|
|
' | sazba=' +
|
|
rate +
|
|
' | výdělek=' +
|
|
(
|
|
minutes /
|
|
60 *
|
|
rate
|
|
)
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
}
|
|
);
|
|
|
|
} |