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

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
)
);
}
);
}
);
}