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.
1660 lines
22 KiB
JavaScript
1660 lines
22 KiB
JavaScript
/* ============================================================
|
||
EATME PORTÁL – DASHBOARD SERVICE
|
||
Optimalizovaná verze
|
||
|
||
Cíle:
|
||
- zachovat stejné veřejné funkce
|
||
- zrychlit opakované čtení stabilnějších tabulek
|
||
- necacheovat živé SHIFTS
|
||
- předindexovat PAY_RATES podle zaměstnance
|
||
============================================================ */
|
||
|
||
|
||
/* ============================================================
|
||
KRÁTKODOBÁ CACHE PRO STABILNĚJŠÍ TABULKY
|
||
|
||
SHIFTS záměrně necachujeme, protože dashboard má ukazovat
|
||
příchody/odchody co nejčerstvěji.
|
||
============================================================ */
|
||
|
||
const DASHBOARD_CACHE_TTL_SECONDS = 20;
|
||
|
||
|
||
/* ============================================================
|
||
CACHE V RÁMCI JEDNOHO APPS SCRIPT BĚHU
|
||
|
||
Zapíná se pouze pro agregované read-only requesty.
|
||
Díky tomu může více služeb ve stejném requestu použít stejná
|
||
data bez dalšího čtení Google Sheets.
|
||
============================================================ */
|
||
|
||
let REQUEST_ROW_CACHE_ENABLED =
|
||
false;
|
||
|
||
|
||
let REQUEST_ROW_CACHE =
|
||
{};
|
||
|
||
|
||
function beginRequestRowCache_() {
|
||
|
||
REQUEST_ROW_CACHE_ENABLED =
|
||
true;
|
||
|
||
|
||
REQUEST_ROW_CACHE =
|
||
{};
|
||
|
||
}
|
||
|
||
|
||
function endRequestRowCache_() {
|
||
|
||
REQUEST_ROW_CACHE_ENABLED =
|
||
false;
|
||
|
||
|
||
REQUEST_ROW_CACHE =
|
||
{};
|
||
|
||
}
|
||
|
||
|
||
function clearRequestRowCache_(
|
||
sheetName
|
||
) {
|
||
|
||
if (
|
||
!sheetName
|
||
) {
|
||
|
||
REQUEST_ROW_CACHE =
|
||
{};
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
delete REQUEST_ROW_CACHE[
|
||
String(
|
||
sheetName
|
||
)
|
||
];
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN DASHBOARD
|
||
============================================================ */
|
||
|
||
function getAdminDashboard(token) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
/*
|
||
* EMPLOYEES, PAY_RATES a žádosti se nemění každou sekundu,
|
||
* proto mohou mít krátkou cache.
|
||
*
|
||
* SHIFTS vždy čteme čerstvě.
|
||
*/
|
||
|
||
const employees =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.EMPLOYEES,
|
||
DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
|
||
|
||
const shifts =
|
||
displayRows_(
|
||
CFG.SHEETS.SHIFTS
|
||
);
|
||
|
||
|
||
const payRates =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.PAY_RATES,
|
||
DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
|
||
|
||
const changeRequests =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.SHIFT_CHANGE_REQUESTS,
|
||
10
|
||
);
|
||
|
||
|
||
const requests =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.REQUESTS,
|
||
10
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
AKTIVNÍ ZAMĚSTNANCI
|
||
========================================= */
|
||
|
||
const activeEmployees =
|
||
employees.filter(
|
||
function(employee) {
|
||
|
||
return isActiveValue_(
|
||
employee.active
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
MAPA ZAMĚSTNANCŮ PODLE ID
|
||
========================================= */
|
||
|
||
const employeeMap =
|
||
{};
|
||
|
||
|
||
employees.forEach(
|
||
function(employee) {
|
||
|
||
employeeMap[
|
||
String(
|
||
employee.employee_id
|
||
)
|
||
] =
|
||
employee;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
INDEX SAZEB PODLE ZAMĚSTNANCE
|
||
|
||
Původní verze pro každou směnu znovu
|
||
filtrovala celý PAY_RATES. Tady to
|
||
připravíme jednou.
|
||
========================================= */
|
||
|
||
const rateIndex =
|
||
buildRateIndex_(
|
||
payRates
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
AKTUÁLNÍ MĚSÍC
|
||
========================================= */
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
const currentPeriod =
|
||
Utilities.formatDate(
|
||
now,
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
OTEVŘENÉ SMĚNY
|
||
========================================= */
|
||
|
||
const openShifts =
|
||
[];
|
||
|
||
|
||
const monthShifts =
|
||
[];
|
||
|
||
|
||
/*
|
||
* Jediný průchod přes SHIFTS.
|
||
* Původně se tabulka filtrovala vícekrát.
|
||
*/
|
||
|
||
shifts.forEach(
|
||
function(shift) {
|
||
|
||
const status =
|
||
String(
|
||
shift.status ||
|
||
''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
status ===
|
||
'OPEN'
|
||
) {
|
||
|
||
openShifts.push(
|
||
shift
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const date =
|
||
parseSheetDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
if (
|
||
!date
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const shiftPeriod =
|
||
Utilities.formatDate(
|
||
date,
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
);
|
||
|
||
|
||
if (
|
||
shiftPeriod ===
|
||
currentPeriod
|
||
) {
|
||
|
||
monthShifts.push(
|
||
shift
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
SOUČTY
|
||
========================================= */
|
||
|
||
let totalWorkedMinutes =
|
||
0;
|
||
|
||
|
||
let estimatedPayroll =
|
||
0;
|
||
|
||
|
||
monthShifts.forEach(
|
||
function(shift) {
|
||
|
||
const workedMinutes =
|
||
numberFromSheet_(
|
||
shift.worked_minutes
|
||
);
|
||
|
||
|
||
totalWorkedMinutes +=
|
||
workedMinutes;
|
||
|
||
|
||
if (
|
||
workedMinutes <=
|
||
0
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const shiftDate =
|
||
parseSheetDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
if (
|
||
!shiftDate
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const hourlyRate =
|
||
getIndexedRateForDate_(
|
||
rateIndex,
|
||
shift.employee_id,
|
||
shiftDate
|
||
);
|
||
|
||
|
||
estimatedPayroll +=
|
||
(
|
||
workedMinutes /
|
||
60
|
||
) *
|
||
hourlyRate;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
KDO JE PRÁVĚ V PRÁCI
|
||
========================================= */
|
||
|
||
const nowMs =
|
||
Date.now();
|
||
|
||
|
||
const openPeople =
|
||
openShifts.map(
|
||
function(shift) {
|
||
|
||
const employee =
|
||
employeeMap[
|
||
String(
|
||
shift.employee_id
|
||
)
|
||
];
|
||
|
||
|
||
const clockIn =
|
||
parseSheetDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
let currentMinutes =
|
||
0;
|
||
|
||
|
||
if (
|
||
clockIn
|
||
) {
|
||
|
||
currentMinutes =
|
||
Math.max(
|
||
0,
|
||
Math.floor(
|
||
(
|
||
nowMs -
|
||
clockIn.getTime()
|
||
) /
|
||
60000
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
let employeeName =
|
||
String(
|
||
shift.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
employee
|
||
) {
|
||
|
||
employeeName =
|
||
(
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim();
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
|
||
shift_id:
|
||
String(
|
||
shift.shift_id ||
|
||
''
|
||
),
|
||
|
||
employee_id:
|
||
String(
|
||
shift.employee_id ||
|
||
''
|
||
),
|
||
|
||
name:
|
||
employeeName,
|
||
|
||
location_id:
|
||
String(
|
||
shift.location_id ||
|
||
''
|
||
),
|
||
|
||
clock_in:
|
||
clockIn
|
||
? clockIn.toISOString()
|
||
: '',
|
||
|
||
current_minutes:
|
||
currentMinutes
|
||
|
||
};
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
ČEKAJÍCÍ OPRAVY
|
||
========================================= */
|
||
|
||
let pendingChangeRequests =
|
||
0;
|
||
|
||
|
||
changeRequests.forEach(
|
||
function(request) {
|
||
|
||
if (
|
||
String(
|
||
request.status ||
|
||
''
|
||
).trim() ===
|
||
'PENDING'
|
||
) {
|
||
|
||
pendingChangeRequests++;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
OSTATNÍ ČEKAJÍCÍ ŽÁDOSTI
|
||
========================================= */
|
||
|
||
let pendingRequests =
|
||
0;
|
||
|
||
|
||
requests.forEach(
|
||
function(request) {
|
||
|
||
if (
|
||
String(
|
||
request.status ||
|
||
''
|
||
).trim() ===
|
||
'PENDING'
|
||
) {
|
||
|
||
pendingRequests++;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
PODEZŘELE DLOUHÉ SMĚNY
|
||
========================================= */
|
||
|
||
let staleOpenShifts =
|
||
0;
|
||
|
||
|
||
openShifts.forEach(
|
||
function(shift) {
|
||
|
||
const clockIn =
|
||
parseSheetDate_(
|
||
shift.clock_in
|
||
);
|
||
|
||
|
||
if (
|
||
!clockIn
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const hours =
|
||
(
|
||
nowMs -
|
||
clockIn.getTime()
|
||
) /
|
||
3600000;
|
||
|
||
|
||
if (
|
||
hours >
|
||
14
|
||
) {
|
||
|
||
staleOpenShifts++;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/* =========================================
|
||
VÝSLEDEK PRO FRONTEND
|
||
========================================= */
|
||
|
||
return {
|
||
|
||
current_period:
|
||
currentPeriod,
|
||
|
||
|
||
stats: {
|
||
|
||
active_employees:
|
||
activeEmployees.length,
|
||
|
||
open_shifts:
|
||
openShifts.length,
|
||
|
||
worked_minutes:
|
||
Math.round(
|
||
totalWorkedMinutes
|
||
),
|
||
|
||
estimated_payroll:
|
||
Math.round(
|
||
estimatedPayroll
|
||
),
|
||
|
||
pending_change_requests:
|
||
pendingChangeRequests,
|
||
|
||
pending_requests:
|
||
pendingRequests,
|
||
|
||
stale_open_shifts:
|
||
staleOpenShifts
|
||
|
||
},
|
||
|
||
|
||
open_people:
|
||
openPeople
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ČTENÍ TABULKY JAKO TEXTŮ
|
||
|
||
Tato funkce zůstává bez cache, protože ji používá celý projekt.
|
||
Tím minimalizujeme riziko, že nějaký zápis nebude okamžitě vidět.
|
||
============================================================ */
|
||
|
||
function displayRows_(sheetName) {
|
||
|
||
const cacheKey =
|
||
String(
|
||
sheetName
|
||
);
|
||
|
||
|
||
if (
|
||
REQUEST_ROW_CACHE_ENABLED &&
|
||
Object.prototype.hasOwnProperty.call(
|
||
REQUEST_ROW_CACHE,
|
||
cacheKey
|
||
)
|
||
) {
|
||
|
||
return REQUEST_ROW_CACHE[
|
||
cacheKey
|
||
];
|
||
|
||
}
|
||
|
||
|
||
const sheet =
|
||
sh_(
|
||
sheetName
|
||
);
|
||
|
||
|
||
const lastRow =
|
||
sheet.getLastRow();
|
||
|
||
|
||
const lastColumn =
|
||
sheet.getLastColumn();
|
||
|
||
|
||
if (
|
||
lastRow <
|
||
2 ||
|
||
lastColumn <
|
||
1
|
||
) {
|
||
|
||
if (
|
||
REQUEST_ROW_CACHE_ENABLED
|
||
) {
|
||
|
||
REQUEST_ROW_CACHE[
|
||
cacheKey
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
return [];
|
||
|
||
}
|
||
|
||
|
||
const values =
|
||
sheet
|
||
.getRange(
|
||
1,
|
||
1,
|
||
lastRow,
|
||
lastColumn
|
||
)
|
||
.getDisplayValues();
|
||
|
||
|
||
const headers =
|
||
values[0]
|
||
.map(
|
||
function(header) {
|
||
|
||
return String(
|
||
header
|
||
).trim();
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const result =
|
||
[];
|
||
|
||
|
||
for (
|
||
let rowIndex = 1;
|
||
rowIndex < values.length;
|
||
rowIndex++
|
||
) {
|
||
|
||
const row =
|
||
values[
|
||
rowIndex
|
||
];
|
||
|
||
|
||
let hasValue =
|
||
false;
|
||
|
||
|
||
for (
|
||
let i = 0;
|
||
i < row.length;
|
||
i++
|
||
) {
|
||
|
||
if (
|
||
String(
|
||
row[i]
|
||
).trim() !==
|
||
''
|
||
) {
|
||
|
||
hasValue =
|
||
true;
|
||
|
||
break;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!hasValue
|
||
) {
|
||
|
||
continue;
|
||
|
||
}
|
||
|
||
|
||
const object =
|
||
{};
|
||
|
||
|
||
for (
|
||
let columnIndex = 0;
|
||
columnIndex < headers.length;
|
||
columnIndex++
|
||
) {
|
||
|
||
const header =
|
||
headers[
|
||
columnIndex
|
||
];
|
||
|
||
|
||
if (
|
||
!header
|
||
) {
|
||
|
||
continue;
|
||
|
||
}
|
||
|
||
|
||
object[
|
||
header
|
||
] =
|
||
row[
|
||
columnIndex
|
||
];
|
||
|
||
}
|
||
|
||
|
||
result.push(
|
||
object
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
REQUEST_ROW_CACHE_ENABLED
|
||
) {
|
||
|
||
REQUEST_ROW_CACHE[
|
||
cacheKey
|
||
] =
|
||
result;
|
||
|
||
}
|
||
|
||
|
||
return result;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
KRÁTKODOBĚ CACHOVANÉ ČTENÍ
|
||
|
||
Používá se pouze tam, kde malé zpoždění nevadí.
|
||
============================================================ */
|
||
|
||
function displayRowsCached_(
|
||
sheetName,
|
||
ttlSeconds
|
||
) {
|
||
|
||
ttlSeconds =
|
||
Number(
|
||
ttlSeconds ||
|
||
DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
|
||
|
||
const cache =
|
||
CacheService
|
||
.getScriptCache();
|
||
|
||
|
||
const key =
|
||
'DISPLAY_ROWS_V1_' +
|
||
String(
|
||
sheetName
|
||
);
|
||
|
||
|
||
try {
|
||
|
||
const cached =
|
||
cache.get(
|
||
key
|
||
);
|
||
|
||
|
||
if (
|
||
cached
|
||
) {
|
||
|
||
return JSON.parse(
|
||
cached
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
catch(error) {
|
||
|
||
/*
|
||
* Cache nesmí nikdy rozbít aplikaci.
|
||
* Při chybě pokračujeme normálním čtením.
|
||
*/
|
||
|
||
}
|
||
|
||
|
||
const rows =
|
||
displayRows_(
|
||
sheetName
|
||
);
|
||
|
||
|
||
/*
|
||
* CacheService má limit velikosti jedné hodnoty.
|
||
* Když je tabulka moc velká, zápis jen přeskočíme.
|
||
*/
|
||
|
||
try {
|
||
|
||
const json =
|
||
JSON.stringify(
|
||
rows
|
||
);
|
||
|
||
|
||
if (
|
||
json.length <
|
||
90000
|
||
) {
|
||
|
||
cache.put(
|
||
key,
|
||
json,
|
||
ttlSeconds
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
catch(error) {
|
||
|
||
/*
|
||
* Opět ignorujeme pouze chybu cache.
|
||
*/
|
||
|
||
}
|
||
|
||
|
||
return rows;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
RUČNÍ SMAZÁNÍ DASHBOARD CACHE
|
||
|
||
Lze spustit ručně při testování.
|
||
============================================================ */
|
||
|
||
function clearDashboardCache() {
|
||
|
||
const cache =
|
||
CacheService
|
||
.getScriptCache();
|
||
|
||
|
||
[
|
||
CFG.SHEETS.EMPLOYEES,
|
||
CFG.SHEETS.PAY_RATES,
|
||
CFG.SHEETS.SHIFT_CHANGE_REQUESTS,
|
||
CFG.SHEETS.REQUESTS
|
||
]
|
||
.forEach(
|
||
function(sheetName) {
|
||
|
||
cache.remove(
|
||
'DISPLAY_ROWS_V1_' +
|
||
String(
|
||
sheetName
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'Dashboard cache cleared'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
INDEX SAZEB
|
||
============================================================ */
|
||
|
||
function buildRateIndex_(rates) {
|
||
|
||
const index =
|
||
{};
|
||
|
||
|
||
rates.forEach(
|
||
function(rate) {
|
||
|
||
const employeeId =
|
||
String(
|
||
rate.employee_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!employeeId
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!index[
|
||
employeeId
|
||
]
|
||
) {
|
||
|
||
index[
|
||
employeeId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
const fromDate =
|
||
parseSheetDate_(
|
||
rate.valid_from
|
||
);
|
||
|
||
|
||
const toDate =
|
||
parseSheetDate_(
|
||
rate.valid_to
|
||
);
|
||
|
||
|
||
index[
|
||
employeeId
|
||
].push(
|
||
{
|
||
|
||
from:
|
||
fromDate
|
||
? Utilities.formatDate(
|
||
fromDate,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
)
|
||
: '0000-00-00',
|
||
|
||
to:
|
||
toDate
|
||
? Utilities.formatDate(
|
||
toDate,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
)
|
||
: '9999-12-31',
|
||
|
||
rate:
|
||
numberFromSheet_(
|
||
rate.hourly_rate
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
Object.keys(
|
||
index
|
||
)
|
||
.forEach(
|
||
function(employeeId) {
|
||
|
||
index[
|
||
employeeId
|
||
].sort(
|
||
function(a,b) {
|
||
|
||
return b.from.localeCompare(
|
||
a.from
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return index;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
SAZBA Z INDEXU
|
||
============================================================ */
|
||
|
||
function getIndexedRateForDate_(
|
||
rateIndex,
|
||
employeeId,
|
||
targetDate
|
||
) {
|
||
|
||
if (
|
||
!targetDate
|
||
) {
|
||
|
||
return 0;
|
||
|
||
}
|
||
|
||
|
||
const employeeRates =
|
||
rateIndex[
|
||
String(
|
||
employeeId
|
||
)
|
||
] ||
|
||
[];
|
||
|
||
|
||
if (
|
||
!employeeRates.length
|
||
) {
|
||
|
||
return 0;
|
||
|
||
}
|
||
|
||
|
||
const target =
|
||
Utilities.formatDate(
|
||
targetDate,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
|
||
for (
|
||
let i = 0;
|
||
i < employeeRates.length;
|
||
i++
|
||
) {
|
||
|
||
const rate =
|
||
employeeRates[
|
||
i
|
||
];
|
||
|
||
|
||
if (
|
||
rate.from <=
|
||
target &&
|
||
rate.to >=
|
||
target
|
||
) {
|
||
|
||
return rate.rate;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
return 0;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
AKTIVNÍ HODNOTA
|
||
============================================================ */
|
||
|
||
function isActiveValue_(value) {
|
||
|
||
const text =
|
||
String(
|
||
value ||
|
||
''
|
||
)
|
||
.trim()
|
||
.toUpperCase();
|
||
|
||
|
||
return (
|
||
text ===
|
||
'TRUE' ||
|
||
|
||
text ===
|
||
'ANO' ||
|
||
|
||
text ===
|
||
'YES' ||
|
||
|
||
text ===
|
||
'1'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ČÍSLO Z GOOGLE SHEETS
|
||
============================================================ */
|
||
|
||
function numberFromSheet_(value) {
|
||
|
||
let text =
|
||
String(
|
||
value ||
|
||
'0'
|
||
)
|
||
.trim()
|
||
.replace(
|
||
/\s/g,
|
||
''
|
||
)
|
||
.replace(
|
||
',',
|
||
'.'
|
||
);
|
||
|
||
|
||
text =
|
||
text.replace(
|
||
/[^0-9.\-]/g,
|
||
''
|
||
);
|
||
|
||
|
||
const number =
|
||
Number(
|
||
text
|
||
);
|
||
|
||
|
||
return isNaN(
|
||
number
|
||
)
|
||
? 0
|
||
: number;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
PARSOVÁNÍ DATUMU
|
||
============================================================ */
|
||
|
||
function parseSheetDate_(value) {
|
||
|
||
const text =
|
||
String(
|
||
value ||
|
||
''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
!text
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
let match;
|
||
|
||
|
||
/* =========================================
|
||
ISO FORMÁT
|
||
|
||
2026-08-11
|
||
2026-08-11 23:44:56
|
||
========================================= */
|
||
|
||
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
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* =========================================
|
||
ČESKÝ FORMÁT
|
||
|
||
11.8.2026
|
||
11.8.2026 23:44:56
|
||
========================================= */
|
||
|
||
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
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* =========================================
|
||
GOOGLE / US FORMÁT
|
||
|
||
8/11/2026
|
||
8/11/2026 23:44:56
|
||
|
||
měsíc / den / rok
|
||
========================================= */
|
||
|
||
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
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* =========================================
|
||
POSLEDNÍ POKUS
|
||
========================================= */
|
||
|
||
const date =
|
||
new Date(
|
||
text
|
||
);
|
||
|
||
|
||
if (
|
||
isNaN(
|
||
date.getTime()
|
||
)
|
||
) {
|
||
|
||
return null;
|
||
|
||
}
|
||
|
||
|
||
return date;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
PŮVODNÍ VEŘEJNÁ FUNKCE PRO SAZBU
|
||
|
||
Zachována kvůli kompatibilitě s ostatními moduly.
|
||
============================================================ */
|
||
|
||
function getDisplayRateForDate_(
|
||
rates,
|
||
employeeId,
|
||
targetDate
|
||
) {
|
||
|
||
if (
|
||
!targetDate
|
||
) {
|
||
|
||
return 0;
|
||
|
||
}
|
||
|
||
|
||
const rateIndex =
|
||
buildRateIndex_(
|
||
rates
|
||
);
|
||
|
||
|
||
return getIndexedRateForDate_(
|
||
rateIndex,
|
||
employeeId,
|
||
targetDate
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA DATABÁZE
|
||
============================================================ */
|
||
|
||
function testDashboardDatabase() {
|
||
|
||
const started =
|
||
Date.now();
|
||
|
||
|
||
const employees =
|
||
displayRows_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const afterEmployees =
|
||
Date.now();
|
||
|
||
|
||
const shifts =
|
||
displayRows_(
|
||
CFG.SHEETS.SHIFTS
|
||
);
|
||
|
||
|
||
const afterShifts =
|
||
Date.now();
|
||
|
||
|
||
Logger.log(
|
||
'EMPLOYEES: ' +
|
||
employees.length +
|
||
' | ' +
|
||
(
|
||
afterEmployees -
|
||
started
|
||
) +
|
||
' ms'
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'SHIFTS: ' +
|
||
shifts.length +
|
||
' | ' +
|
||
(
|
||
afterShifts -
|
||
afterEmployees
|
||
) +
|
||
' ms'
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'TOTAL: ' +
|
||
(
|
||
afterShifts -
|
||
started
|
||
) +
|
||
' ms'
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
employees:
|
||
employees.length,
|
||
|
||
shifts:
|
||
shifts.length,
|
||
|
||
employees_ms:
|
||
afterEmployees -
|
||
started,
|
||
|
||
shifts_ms:
|
||
afterShifts -
|
||
afterEmployees,
|
||
|
||
total_ms:
|
||
afterShifts -
|
||
started
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA ADMIN DASHBOARDU
|
||
============================================================ */
|
||
|
||
function testAdminDashboardPerformance() {
|
||
|
||
const started =
|
||
Date.now();
|
||
|
||
|
||
/*
|
||
* Test vyžaduje validní token při volání přes frontend,
|
||
* proto zde měříme pouze datovou část.
|
||
*/
|
||
|
||
const employees =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.EMPLOYEES,
|
||
DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
|
||
|
||
const shifts =
|
||
displayRows_(
|
||
CFG.SHEETS.SHIFTS
|
||
);
|
||
|
||
|
||
const payRates =
|
||
displayRowsCached_(
|
||
CFG.SHEETS.PAY_RATES,
|
||
DASHBOARD_CACHE_TTL_SECONDS
|
||
);
|
||
|
||
|
||
const rateIndex =
|
||
buildRateIndex_(
|
||
payRates
|
||
);
|
||
|
||
|
||
const finished =
|
||
Date.now();
|
||
|
||
|
||
Logger.log(
|
||
'EMPLOYEES: ' +
|
||
employees.length
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'SHIFTS: ' +
|
||
shifts.length
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'PAY_RATES: ' +
|
||
payRates.length
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'RATE INDEX EMPLOYEES: ' +
|
||
Object.keys(
|
||
rateIndex
|
||
).length
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'TOTAL: ' +
|
||
(
|
||
finished -
|
||
started
|
||
) +
|
||
' ms'
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
total_ms:
|
||
finished -
|
||
started,
|
||
|
||
employees:
|
||
employees.length,
|
||
|
||
shifts:
|
||
shifts.length,
|
||
|
||
pay_rates:
|
||
payRates.length
|
||
|
||
};
|
||
|
||
}
|