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

1660 lines
22 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 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
};
}