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.
2867 lines
36 KiB
JavaScript
2867 lines
36 KiB
JavaScript
/* ============================================================
|
||
MESTAFF – SHIFT PLANNING SERVICE
|
||
Plánované směny jsou oddělené od skutečné docházky.
|
||
|
||
Výchozí pravidla:
|
||
NE–ČT 18:00–23:00, kapacita 1
|
||
PÁ–SO 18:00–02:00, kapacita 1
|
||
20:00–00:00, kapacita 1
|
||
|
||
Automatické vytvoření dalšího měsíce:
|
||
každý 10. den předchozího měsíce kolem 17:00
|
||
============================================================ */
|
||
|
||
const SHIFT_PLAN_SHEETS = {
|
||
SLOTS: 'SHIFT_SLOTS',
|
||
SIGNUPS: 'SHIFT_SIGNUPS'
|
||
};
|
||
|
||
|
||
/* ============================================================
|
||
JEDNORÁZOVÝ SETUP
|
||
============================================================ */
|
||
|
||
function setupShiftPlanning() {
|
||
|
||
ensureShiftPlanningSheet_(
|
||
SHIFT_PLAN_SHEETS.SLOTS,
|
||
[
|
||
'slot_id',
|
||
'date',
|
||
'start_time',
|
||
'end_time',
|
||
'capacity',
|
||
'location_id',
|
||
'status',
|
||
'note',
|
||
'generated',
|
||
'created_at',
|
||
'updated_at'
|
||
]
|
||
);
|
||
|
||
ensureShiftPlanningSheet_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS,
|
||
[
|
||
'signup_id',
|
||
'slot_id',
|
||
'employee_id',
|
||
'status',
|
||
'created_at',
|
||
'cancelled_at',
|
||
'cancelled_by'
|
||
]
|
||
);
|
||
|
||
createShiftPlanningTrigger_();
|
||
|
||
Logger.log('Shift planning setup OK');
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
AUTOMATICKÝ TRIGGER
|
||
|
||
Apps Script měsíční trigger neumí garantovat přesně 17:00:00.
|
||
atHour(17) znamená spuštění v hodinovém okně 17:00–18:00.
|
||
============================================================ */
|
||
|
||
function createShiftPlanningTrigger_() {
|
||
|
||
const handler =
|
||
'generateNextMonthShiftSlots';
|
||
|
||
|
||
ScriptApp
|
||
.getProjectTriggers()
|
||
.filter(
|
||
function(trigger) {
|
||
|
||
return (
|
||
trigger.getHandlerFunction() ===
|
||
handler
|
||
);
|
||
|
||
}
|
||
)
|
||
.forEach(
|
||
function(trigger) {
|
||
|
||
ScriptApp.deleteTrigger(
|
||
trigger
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
ScriptApp
|
||
.newTrigger(
|
||
handler
|
||
)
|
||
.timeBased()
|
||
.onMonthDay(
|
||
10
|
||
)
|
||
.atHour(
|
||
17
|
||
)
|
||
.create();
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
GENERÁTOR DALŠÍHO MĚSÍCE
|
||
============================================================ */
|
||
|
||
function generateNextMonthShiftSlots() {
|
||
|
||
const now =
|
||
new Date();
|
||
|
||
|
||
let year =
|
||
now.getFullYear();
|
||
|
||
|
||
let month =
|
||
now.getMonth() + 1;
|
||
|
||
|
||
if (
|
||
month > 11
|
||
) {
|
||
|
||
month =
|
||
0;
|
||
|
||
year++;
|
||
|
||
}
|
||
|
||
|
||
return generateShiftSlotsForMonth_(
|
||
year,
|
||
month
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
RUČNÝ GENERÁTOR PRO ADMINA
|
||
|
||
monthNumber = 1–12
|
||
============================================================ */
|
||
|
||
function adminGenerateShiftMonth(
|
||
token,
|
||
year,
|
||
monthNumber
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
year =
|
||
Number(
|
||
year
|
||
);
|
||
|
||
|
||
monthNumber =
|
||
Number(
|
||
monthNumber
|
||
);
|
||
|
||
|
||
if (
|
||
!year ||
|
||
monthNumber < 1 ||
|
||
monthNumber > 12
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný měsíc.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return generateShiftSlotsForMonth_(
|
||
year,
|
||
monthNumber - 1
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
VLASTNÍ GENERÁTOR
|
||
|
||
monthIndex = JS měsíc 0–11
|
||
============================================================ */
|
||
|
||
function generateShiftSlotsForMonth_(
|
||
year,
|
||
monthIndex
|
||
) {
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const existingKeys =
|
||
{};
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
existingKeys[
|
||
shiftSlotKey_(
|
||
slot.date,
|
||
slot.start_time,
|
||
slot.end_time,
|
||
slot.location_id
|
||
)
|
||
] =
|
||
true;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const defaultLocation =
|
||
getDefaultPlanningLocation_();
|
||
|
||
|
||
const days =
|
||
new Date(
|
||
year,
|
||
monthIndex + 1,
|
||
0
|
||
).getDate();
|
||
|
||
|
||
let created =
|
||
0;
|
||
|
||
|
||
for (
|
||
let day = 1;
|
||
day <= days;
|
||
day++
|
||
) {
|
||
|
||
const date =
|
||
new Date(
|
||
year,
|
||
monthIndex,
|
||
day
|
||
);
|
||
|
||
|
||
const dow =
|
||
date.getDay();
|
||
|
||
|
||
const dateText =
|
||
Utilities.formatDate(
|
||
date,
|
||
CFG.TZ,
|
||
'yyyy-MM-dd'
|
||
);
|
||
|
||
|
||
let templates =
|
||
[];
|
||
|
||
|
||
/*
|
||
* neděle = 0
|
||
* pondělí = 1
|
||
* ...
|
||
* čtvrtek = 4
|
||
*/
|
||
|
||
if (
|
||
dow === 0 ||
|
||
(
|
||
dow >= 1 &&
|
||
dow <= 4
|
||
)
|
||
) {
|
||
|
||
templates =
|
||
[
|
||
{
|
||
start:'18:00',
|
||
end:'23:00',
|
||
capacity:1
|
||
}
|
||
];
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* pátek + sobota
|
||
*/
|
||
|
||
if (
|
||
dow === 5 ||
|
||
dow === 6
|
||
) {
|
||
|
||
templates =
|
||
[
|
||
{
|
||
start:'18:00',
|
||
end:'02:00',
|
||
capacity:1
|
||
},
|
||
{
|
||
start:'20:00',
|
||
end:'00:00',
|
||
capacity:1
|
||
}
|
||
];
|
||
|
||
}
|
||
|
||
|
||
templates.forEach(
|
||
function(template) {
|
||
|
||
const key =
|
||
shiftSlotKey_(
|
||
dateText,
|
||
template.start,
|
||
template.end,
|
||
defaultLocation
|
||
);
|
||
|
||
|
||
/*
|
||
* idempotence:
|
||
* při opakovaném spuštění nevznikne duplicita
|
||
*/
|
||
|
||
if (
|
||
existingKeys[
|
||
key
|
||
]
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
append_(
|
||
SHIFT_PLAN_SHEETS.SLOTS,
|
||
{
|
||
|
||
slot_id:
|
||
uuid_(
|
||
'SLOT'
|
||
),
|
||
|
||
date:
|
||
dateText,
|
||
|
||
start_time:
|
||
template.start,
|
||
|
||
end_time:
|
||
template.end,
|
||
|
||
capacity:
|
||
template.capacity,
|
||
|
||
location_id:
|
||
defaultLocation,
|
||
|
||
status:
|
||
'OPEN_FOR_SIGNUP',
|
||
|
||
note:
|
||
'',
|
||
|
||
generated:
|
||
true,
|
||
|
||
created_at:
|
||
now_(),
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
existingKeys[
|
||
key
|
||
] =
|
||
true;
|
||
|
||
|
||
created++;
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
|
||
ok:true,
|
||
|
||
year:
|
||
year,
|
||
|
||
month:
|
||
monthIndex + 1,
|
||
|
||
created:
|
||
created
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – MĚSÍČNÍ PLÁN
|
||
============================================================ */
|
||
|
||
function getAdminShiftPlan(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
period =
|
||
normalizePlanningPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
);
|
||
|
||
|
||
const employees =
|
||
displayRows_(
|
||
CFG.SHEETS.EMPLOYEES
|
||
);
|
||
|
||
|
||
const employeeMap =
|
||
{};
|
||
|
||
|
||
employees.forEach(
|
||
function(employee) {
|
||
|
||
employeeMap[
|
||
String(
|
||
employee.employee_id
|
||
)
|
||
] =
|
||
employee;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const signupsBySlot =
|
||
buildApprovedSignupsBySlot_(
|
||
signups
|
||
);
|
||
|
||
|
||
return slots
|
||
.filter(
|
||
function(slot) {
|
||
|
||
return (
|
||
String(
|
||
slot.date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
7
|
||
) ===
|
||
period
|
||
);
|
||
|
||
}
|
||
)
|
||
.map(
|
||
function(slot) {
|
||
|
||
const slotSignups =
|
||
signupsBySlot[
|
||
String(
|
||
slot.slot_id
|
||
)
|
||
] ||
|
||
[];
|
||
|
||
|
||
return {
|
||
|
||
slot_id:
|
||
String(
|
||
slot.slot_id ||
|
||
''
|
||
),
|
||
|
||
date:
|
||
String(
|
||
slot.date ||
|
||
''
|
||
),
|
||
|
||
start_time:
|
||
String(
|
||
slot.start_time ||
|
||
''
|
||
),
|
||
|
||
end_time:
|
||
String(
|
||
slot.end_time ||
|
||
''
|
||
),
|
||
|
||
capacity:
|
||
planningNumber_(
|
||
slot.capacity
|
||
),
|
||
|
||
location_id:
|
||
String(
|
||
slot.location_id ||
|
||
''
|
||
),
|
||
|
||
status:
|
||
String(
|
||
slot.status ||
|
||
''
|
||
),
|
||
|
||
note:
|
||
String(
|
||
slot.note ||
|
||
''
|
||
),
|
||
|
||
occupied:
|
||
slotSignups.length,
|
||
|
||
employees:
|
||
slotSignups.map(
|
||
function(signup) {
|
||
|
||
const employee =
|
||
employeeMap[
|
||
String(
|
||
signup.employee_id
|
||
)
|
||
];
|
||
|
||
|
||
return {
|
||
|
||
employee_id:
|
||
String(
|
||
signup.employee_id ||
|
||
''
|
||
),
|
||
|
||
name:
|
||
employee
|
||
? (
|
||
String(
|
||
employee.first_name ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
employee.last_name ||
|
||
''
|
||
)
|
||
).trim()
|
||
: String(
|
||
signup.employee_id ||
|
||
''
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
)
|
||
.sort(
|
||
shiftSlotSort_
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – ÚPRAVA KONKRÉTNÍ SMĚNY
|
||
============================================================ */
|
||
|
||
function updateShiftSlot(
|
||
token,
|
||
slotId,
|
||
data
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const slot =
|
||
slots.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!slot
|
||
) {
|
||
|
||
throw new Error(
|
||
'Směna nebyla nalezena.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const capacity =
|
||
Number(
|
||
data.capacity
|
||
);
|
||
|
||
|
||
if (
|
||
!Number.isInteger(
|
||
capacity
|
||
) ||
|
||
capacity < 0
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kapacita musí být celé číslo 0 nebo vyšší.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const signups =
|
||
getApprovedSlotSignups_(
|
||
slotId
|
||
);
|
||
|
||
|
||
if (
|
||
capacity <
|
||
signups.length
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const status =
|
||
capacity === 0
|
||
? 'CLOSED'
|
||
: (
|
||
signups.length >=
|
||
capacity
|
||
? 'FULL'
|
||
: 'OPEN_FOR_SIGNUP'
|
||
);
|
||
|
||
|
||
updateBy_(
|
||
SHIFT_PLAN_SHEETS.SLOTS,
|
||
'slot_id',
|
||
slotId,
|
||
{
|
||
|
||
date:
|
||
String(
|
||
data.date ||
|
||
slot.date
|
||
),
|
||
|
||
start_time:
|
||
normalizePlanningTime_(
|
||
data.start_time
|
||
),
|
||
|
||
end_time:
|
||
normalizePlanningTime_(
|
||
data.end_time
|
||
),
|
||
|
||
capacity:
|
||
capacity,
|
||
|
||
status:
|
||
status,
|
||
|
||
note:
|
||
String(
|
||
data.note ||
|
||
''
|
||
),
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'SHIFT_SLOT_UPDATED',
|
||
'SHIFT_SLOT',
|
||
slotId,
|
||
JSON.stringify(
|
||
slot
|
||
),
|
||
JSON.stringify(
|
||
data
|
||
)
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – PŘIDÁNÍ MIMOŘÁDNÉ SMĚNY
|
||
============================================================ */
|
||
|
||
function createShiftSlot(
|
||
token,
|
||
data
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
const date =
|
||
String(
|
||
data.date ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!/^\d{4}-\d{2}-\d{2}$/.test(
|
||
date
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatné datum.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const startTime =
|
||
normalizePlanningTime_(
|
||
data.start_time
|
||
);
|
||
|
||
|
||
const endTime =
|
||
normalizePlanningTime_(
|
||
data.end_time
|
||
);
|
||
|
||
|
||
const capacity =
|
||
Number(
|
||
data.capacity
|
||
);
|
||
|
||
|
||
if (
|
||
!Number.isInteger(
|
||
capacity
|
||
) ||
|
||
capacity < 1
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kapacita musí být alespoň 1.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const locationId =
|
||
String(
|
||
data.location_id ||
|
||
getDefaultPlanningLocation_()
|
||
);
|
||
|
||
|
||
const existing =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
)
|
||
.find(
|
||
function(slot) {
|
||
|
||
return (
|
||
shiftSlotKey_(
|
||
slot.date,
|
||
slot.start_time,
|
||
slot.end_time,
|
||
slot.location_id
|
||
) ===
|
||
shiftSlotKey_(
|
||
date,
|
||
startTime,
|
||
endTime,
|
||
locationId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
existing
|
||
) {
|
||
|
||
throw new Error(
|
||
'Stejná směna už existuje.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const slotId =
|
||
uuid_(
|
||
'SLOT'
|
||
);
|
||
|
||
|
||
append_(
|
||
SHIFT_PLAN_SHEETS.SLOTS,
|
||
{
|
||
|
||
slot_id:
|
||
slotId,
|
||
|
||
date:
|
||
date,
|
||
|
||
start_time:
|
||
startTime,
|
||
|
||
end_time:
|
||
endTime,
|
||
|
||
capacity:
|
||
capacity,
|
||
|
||
location_id:
|
||
locationId,
|
||
|
||
status:
|
||
'OPEN_FOR_SIGNUP',
|
||
|
||
note:
|
||
String(
|
||
data.note ||
|
||
''
|
||
),
|
||
|
||
generated:
|
||
false,
|
||
|
||
created_at:
|
||
now_(),
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'SHIFT_SLOT_CREATED',
|
||
'SHIFT_SLOT',
|
||
slotId,
|
||
'',
|
||
JSON.stringify(
|
||
data
|
||
)
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true,
|
||
|
||
slot_id:
|
||
slotId
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – RUČNÍ PŘIDÁNÍ ČLOVĚKA NA SMĚNU
|
||
============================================================ */
|
||
|
||
function adminAssignEmployeeToSlot(
|
||
token,
|
||
slotId,
|
||
employeeId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
return signupEmployeeToSlot_(
|
||
slotId,
|
||
employeeId,
|
||
user.user_id
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ADMIN – ODEBRÁNÍ ČLOVĚKA ZE SMĚNY
|
||
============================================================ */
|
||
|
||
function adminRemoveEmployeeFromSlot(
|
||
token,
|
||
slotId,
|
||
employeeId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'ADMIN',
|
||
'MANAGER'
|
||
]
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
);
|
||
|
||
|
||
const signup =
|
||
signups.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
) &&
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
row.status
|
||
) ===
|
||
'APPROVED'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!signup
|
||
) {
|
||
|
||
throw new Error(
|
||
'Přihlášení na směnu nebylo nalezeno.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS,
|
||
'signup_id',
|
||
signup.signup_id,
|
||
{
|
||
|
||
status:
|
||
'CANCELLED',
|
||
|
||
cancelled_at:
|
||
now_(),
|
||
|
||
cancelled_by:
|
||
user.user_id
|
||
|
||
}
|
||
);
|
||
|
||
|
||
refreshSlotStatus_(
|
||
slotId
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'SHIFT_SIGNUP_REMOVED',
|
||
'SHIFT_SLOT',
|
||
slotId,
|
||
employeeId,
|
||
''
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
RYCHLÝ BUNDLE PRO ZAMĚSTNANECKÉ SMĚNY
|
||
|
||
Jeden request + jedno čtení SHIFT_SLOTS + jedno čtení
|
||
SHIFT_SIGNUPS.
|
||
============================================================ */
|
||
|
||
function getEmployeeShiftPlanningBundle(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
period =
|
||
normalizePlanningPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
available:
|
||
buildAvailableShiftSlotsFromRows_(
|
||
user.employee_id,
|
||
period,
|
||
slots,
|
||
signups
|
||
),
|
||
|
||
mine:
|
||
buildMyPlannedShiftsFromRows_(
|
||
user.employee_id,
|
||
period,
|
||
slots,
|
||
signups
|
||
)
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
INDEX PŘIHLÁŠEK PODLE SLOTU
|
||
============================================================ */
|
||
|
||
function buildApprovedSignupsBySlot_(
|
||
signups
|
||
) {
|
||
|
||
const result =
|
||
{};
|
||
|
||
|
||
signups.forEach(
|
||
function(signup) {
|
||
|
||
if (
|
||
String(
|
||
signup.status
|
||
) !==
|
||
'APPROVED'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const slotId =
|
||
String(
|
||
signup.slot_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
!slotId
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
!result[
|
||
slotId
|
||
]
|
||
) {
|
||
|
||
result[
|
||
slotId
|
||
] =
|
||
[];
|
||
|
||
}
|
||
|
||
|
||
result[
|
||
slotId
|
||
].push(
|
||
signup
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return result;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
VOLNÉ SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ
|
||
============================================================ */
|
||
|
||
function buildAvailableShiftSlotsFromRows_(
|
||
employeeId,
|
||
period,
|
||
slots,
|
||
signups
|
||
) {
|
||
|
||
const employeeKey =
|
||
String(
|
||
employeeId
|
||
);
|
||
|
||
|
||
const signupsBySlot =
|
||
buildApprovedSignupsBySlot_(
|
||
signups
|
||
);
|
||
|
||
|
||
const mySlotMap =
|
||
{};
|
||
|
||
|
||
signups.forEach(
|
||
function(signup) {
|
||
|
||
if (
|
||
String(
|
||
signup.employee_id
|
||
) ===
|
||
employeeKey &&
|
||
String(
|
||
signup.status
|
||
) ===
|
||
'APPROVED'
|
||
) {
|
||
|
||
mySlotMap[
|
||
String(
|
||
signup.slot_id
|
||
)
|
||
] =
|
||
true;
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const result =
|
||
[];
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
if (
|
||
String(
|
||
slot.date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
7
|
||
) !==
|
||
period
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
slot.status
|
||
) !==
|
||
'OPEN_FOR_SIGNUP'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const slotId =
|
||
String(
|
||
slot.slot_id ||
|
||
''
|
||
);
|
||
|
||
|
||
if (
|
||
mySlotMap[
|
||
slotId
|
||
]
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const occupied =
|
||
(
|
||
signupsBySlot[
|
||
slotId
|
||
] ||
|
||
[]
|
||
).length;
|
||
|
||
|
||
const capacity =
|
||
planningNumber_(
|
||
slot.capacity
|
||
);
|
||
|
||
|
||
const freePlaces =
|
||
Math.max(
|
||
0,
|
||
capacity -
|
||
occupied
|
||
);
|
||
|
||
|
||
if (
|
||
freePlaces <=
|
||
0
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
result.push(
|
||
{
|
||
|
||
slot_id:
|
||
slotId,
|
||
|
||
date:
|
||
String(
|
||
slot.date ||
|
||
''
|
||
),
|
||
|
||
start_time:
|
||
String(
|
||
slot.start_time ||
|
||
''
|
||
),
|
||
|
||
end_time:
|
||
String(
|
||
slot.end_time ||
|
||
''
|
||
),
|
||
|
||
capacity:
|
||
capacity,
|
||
|
||
occupied:
|
||
occupied,
|
||
|
||
free_places:
|
||
freePlaces,
|
||
|
||
already_signed:
|
||
false,
|
||
|
||
note:
|
||
String(
|
||
slot.note ||
|
||
''
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return result.sort(
|
||
shiftSlotSort_
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
MOJE SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ
|
||
============================================================ */
|
||
|
||
function buildMyPlannedShiftsFromRows_(
|
||
employeeId,
|
||
period,
|
||
slots,
|
||
signups
|
||
) {
|
||
|
||
const slotMap =
|
||
{};
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
slotMap[
|
||
String(
|
||
slot.slot_id
|
||
)
|
||
] =
|
||
slot;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const result =
|
||
[];
|
||
|
||
|
||
signups.forEach(
|
||
function(signup) {
|
||
|
||
if (
|
||
String(
|
||
signup.employee_id
|
||
) !==
|
||
String(
|
||
employeeId
|
||
) ||
|
||
String(
|
||
signup.status
|
||
) !==
|
||
'APPROVED'
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const slot =
|
||
slotMap[
|
||
String(
|
||
signup.slot_id
|
||
)
|
||
];
|
||
|
||
|
||
if (
|
||
!slot
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
slot.date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
7
|
||
) !==
|
||
period
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
result.push(
|
||
{
|
||
|
||
signup_id:
|
||
String(
|
||
signup.signup_id ||
|
||
''
|
||
),
|
||
|
||
slot_id:
|
||
String(
|
||
slot.slot_id ||
|
||
''
|
||
),
|
||
|
||
date:
|
||
String(
|
||
slot.date ||
|
||
''
|
||
),
|
||
|
||
start_time:
|
||
String(
|
||
slot.start_time ||
|
||
''
|
||
),
|
||
|
||
end_time:
|
||
String(
|
||
slot.end_time ||
|
||
''
|
||
),
|
||
|
||
note:
|
||
String(
|
||
slot.note ||
|
||
''
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return result.sort(
|
||
shiftSlotSort_
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – VOLNÉ SMĚNY
|
||
============================================================ */
|
||
|
||
function getAvailableShiftSlots(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
period =
|
||
normalizePlanningPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
);
|
||
|
||
|
||
return buildAvailableShiftSlotsFromRows_(
|
||
user.employee_id,
|
||
period,
|
||
slots,
|
||
signups
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – PŘIHLÁŠENÍ NA SMĚNU
|
||
============================================================ */
|
||
|
||
function signupForShiftSlot(
|
||
token,
|
||
slotId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return signupEmployeeToSlot_(
|
||
slotId,
|
||
user.employee_id,
|
||
user.user_id
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – MOJE SMĚNY
|
||
============================================================ */
|
||
|
||
function getMyPlannedShifts(
|
||
token,
|
||
period
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
period =
|
||
normalizePlanningPeriod_(
|
||
period
|
||
);
|
||
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const signups =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
);
|
||
|
||
|
||
return buildMyPlannedShiftsFromRows_(
|
||
user.employee_id,
|
||
period,
|
||
slots,
|
||
signups
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
ZAMĚSTNANEC – ZRUŠENÍ PŘIHLÁŠKY
|
||
============================================================ */
|
||
|
||
function cancelMyShiftSignup(
|
||
token,
|
||
slotId
|
||
) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token,
|
||
[
|
||
'EMPLOYEE',
|
||
'MANAGER',
|
||
'ADMIN'
|
||
]
|
||
);
|
||
|
||
|
||
if (
|
||
!user.employee_id
|
||
) {
|
||
|
||
throw new Error(
|
||
'Účet není propojen se zaměstnancem.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const signup =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
)
|
||
.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
) &&
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
user.employee_id
|
||
) &&
|
||
String(
|
||
row.status
|
||
) ===
|
||
'APPROVED'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!signup
|
||
) {
|
||
|
||
throw new Error(
|
||
'Na této směně nejsi přihlášen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS,
|
||
'signup_id',
|
||
signup.signup_id,
|
||
{
|
||
|
||
status:
|
||
'CANCELLED',
|
||
|
||
cancelled_at:
|
||
now_(),
|
||
|
||
cancelled_by:
|
||
user.user_id
|
||
|
||
}
|
||
);
|
||
|
||
|
||
refreshSlotStatus_(
|
||
slotId
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
INTERNÍ PŘIHLÁŠENÍ
|
||
============================================================ */
|
||
|
||
function signupEmployeeToSlot_(
|
||
slotId,
|
||
employeeId,
|
||
actorUserId
|
||
) {
|
||
|
||
const lock =
|
||
LockService
|
||
.getScriptLock();
|
||
|
||
|
||
lock.waitLock(
|
||
10000
|
||
);
|
||
|
||
|
||
try {
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const slot =
|
||
slots.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!slot
|
||
) {
|
||
|
||
throw new Error(
|
||
'Směna nebyla nalezena.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
if (
|
||
String(
|
||
slot.status
|
||
) ===
|
||
'CLOSED'
|
||
) {
|
||
|
||
throw new Error(
|
||
'Směna je uzavřená.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const existing =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
)
|
||
.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
) &&
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
row.status
|
||
) ===
|
||
'APPROVED'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
existing
|
||
) {
|
||
|
||
throw new Error(
|
||
'Na této směně už jsi přihlášen.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
assertNoPlanningCollision_(
|
||
employeeId,
|
||
slot
|
||
);
|
||
|
||
|
||
const approved =
|
||
getApprovedSlotSignups_(
|
||
slotId
|
||
);
|
||
|
||
|
||
const capacity =
|
||
planningNumber_(
|
||
slot.capacity
|
||
);
|
||
|
||
|
||
if (
|
||
approved.length >=
|
||
capacity
|
||
) {
|
||
|
||
throw new Error(
|
||
'Směna je už obsazená.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
append_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS,
|
||
{
|
||
|
||
signup_id:
|
||
uuid_(
|
||
'SIGN'
|
||
),
|
||
|
||
slot_id:
|
||
slotId,
|
||
|
||
employee_id:
|
||
employeeId,
|
||
|
||
status:
|
||
'APPROVED',
|
||
|
||
created_at:
|
||
now_(),
|
||
|
||
cancelled_at:
|
||
'',
|
||
|
||
cancelled_by:
|
||
''
|
||
|
||
}
|
||
);
|
||
|
||
|
||
refreshSlotStatus_(
|
||
slotId
|
||
);
|
||
|
||
|
||
audit_(
|
||
actorUserId,
|
||
'SHIFT_SIGNUP_CREATED',
|
||
'SHIFT_SLOT',
|
||
slotId,
|
||
'',
|
||
employeeId
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:true
|
||
|
||
};
|
||
|
||
}
|
||
|
||
finally {
|
||
|
||
lock.releaseLock();
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
KOLIZE SMĚN
|
||
============================================================ */
|
||
|
||
function assertNoPlanningCollision_(
|
||
employeeId,
|
||
targetSlot
|
||
) {
|
||
|
||
const myShifts =
|
||
getApprovedPlannedSlotsForEmployee_(
|
||
employeeId
|
||
);
|
||
|
||
|
||
const targetStart =
|
||
planningSlotStart_(
|
||
targetSlot
|
||
);
|
||
|
||
|
||
const targetEnd =
|
||
planningSlotEnd_(
|
||
targetSlot
|
||
);
|
||
|
||
|
||
myShifts.forEach(
|
||
function(slot) {
|
||
|
||
const start =
|
||
planningSlotStart_(
|
||
slot
|
||
);
|
||
|
||
|
||
const end =
|
||
planningSlotEnd_(
|
||
slot
|
||
);
|
||
|
||
|
||
if (
|
||
targetStart <
|
||
end &&
|
||
targetEnd >
|
||
start
|
||
) {
|
||
|
||
throw new Error(
|
||
'Tato směna se překrývá s jinou směnou, na kterou už jsi přihlášen.'
|
||
);
|
||
|
||
}
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
POMOCNÉ FUNKCE
|
||
============================================================ */
|
||
|
||
function getApprovedSlotSignups_(
|
||
slotId
|
||
) {
|
||
|
||
return displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
)
|
||
.filter(
|
||
function(signup) {
|
||
|
||
return (
|
||
String(
|
||
signup.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
) &&
|
||
String(
|
||
signup.status
|
||
) ===
|
||
'APPROVED'
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function getApprovedPlannedSlotsForEmployee_(
|
||
employeeId
|
||
) {
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const slotMap =
|
||
{};
|
||
|
||
|
||
slots.forEach(
|
||
function(slot) {
|
||
|
||
slotMap[
|
||
String(
|
||
slot.slot_id
|
||
)
|
||
] =
|
||
slot;
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
)
|
||
.filter(
|
||
function(signup) {
|
||
|
||
return (
|
||
String(
|
||
signup.employee_id
|
||
) ===
|
||
String(
|
||
employeeId
|
||
) &&
|
||
String(
|
||
signup.status
|
||
) ===
|
||
'APPROVED'
|
||
);
|
||
|
||
}
|
||
)
|
||
.map(
|
||
function(signup) {
|
||
|
||
return slotMap[
|
||
String(
|
||
signup.slot_id
|
||
)
|
||
];
|
||
|
||
}
|
||
)
|
||
.filter(
|
||
Boolean
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function refreshSlotStatus_(
|
||
slotId
|
||
) {
|
||
|
||
const slots =
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
);
|
||
|
||
|
||
const slot =
|
||
slots.find(
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.slot_id
|
||
) ===
|
||
String(
|
||
slotId
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!slot
|
||
) {
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
|
||
const capacity =
|
||
planningNumber_(
|
||
slot.capacity
|
||
);
|
||
|
||
|
||
const occupied =
|
||
getApprovedSlotSignups_(
|
||
slotId
|
||
).length;
|
||
|
||
|
||
let status =
|
||
'OPEN_FOR_SIGNUP';
|
||
|
||
|
||
if (
|
||
capacity <= 0
|
||
) {
|
||
|
||
status =
|
||
'CLOSED';
|
||
|
||
} else if (
|
||
occupied >=
|
||
capacity
|
||
) {
|
||
|
||
status =
|
||
'FULL';
|
||
|
||
}
|
||
|
||
|
||
updateBy_(
|
||
SHIFT_PLAN_SHEETS.SLOTS,
|
||
'slot_id',
|
||
slotId,
|
||
{
|
||
|
||
status:
|
||
status,
|
||
|
||
updated_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function planningSlotStart_(
|
||
slot
|
||
) {
|
||
|
||
const parts =
|
||
String(
|
||
slot.date
|
||
).split(
|
||
'-'
|
||
);
|
||
|
||
|
||
const time =
|
||
String(
|
||
slot.start_time
|
||
).split(
|
||
':'
|
||
);
|
||
|
||
|
||
return new Date(
|
||
Number(
|
||
parts[0]
|
||
),
|
||
Number(
|
||
parts[1]
|
||
) - 1,
|
||
Number(
|
||
parts[2]
|
||
),
|
||
Number(
|
||
time[0]
|
||
),
|
||
Number(
|
||
time[1] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function planningSlotEnd_(
|
||
slot
|
||
) {
|
||
|
||
const start =
|
||
planningSlotStart_(
|
||
slot
|
||
);
|
||
|
||
|
||
const time =
|
||
String(
|
||
slot.end_time
|
||
).split(
|
||
':'
|
||
);
|
||
|
||
|
||
const end =
|
||
new Date(
|
||
start.getFullYear(),
|
||
start.getMonth(),
|
||
start.getDate(),
|
||
Number(
|
||
time[0]
|
||
),
|
||
Number(
|
||
time[1] ||
|
||
0
|
||
)
|
||
);
|
||
|
||
|
||
if (
|
||
end <=
|
||
start
|
||
) {
|
||
|
||
end.setDate(
|
||
end.getDate() +
|
||
1
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return end;
|
||
|
||
}
|
||
|
||
|
||
function shiftSlotKey_(
|
||
date,
|
||
start,
|
||
end,
|
||
locationId
|
||
) {
|
||
|
||
return [
|
||
String(
|
||
date ||
|
||
''
|
||
).substring(
|
||
0,
|
||
10
|
||
),
|
||
String(
|
||
start ||
|
||
''
|
||
).substring(
|
||
0,
|
||
5
|
||
),
|
||
String(
|
||
end ||
|
||
''
|
||
).substring(
|
||
0,
|
||
5
|
||
),
|
||
String(
|
||
locationId ||
|
||
''
|
||
)
|
||
].join(
|
||
'|'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function shiftSlotSort_(
|
||
a,
|
||
b
|
||
) {
|
||
|
||
const aKey =
|
||
String(
|
||
a.date ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
a.start_time ||
|
||
''
|
||
);
|
||
|
||
|
||
const bKey =
|
||
String(
|
||
b.date ||
|
||
''
|
||
) +
|
||
' ' +
|
||
String(
|
||
b.start_time ||
|
||
''
|
||
);
|
||
|
||
|
||
return aKey.localeCompare(
|
||
bKey
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function normalizePlanningPeriod_(
|
||
period
|
||
) {
|
||
|
||
period =
|
||
String(
|
||
period ||
|
||
Utilities.formatDate(
|
||
new Date(),
|
||
CFG.TZ,
|
||
'yyyy-MM'
|
||
)
|
||
);
|
||
|
||
|
||
if (
|
||
!/^\d{4}-\d{2}$/.test(
|
||
period
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatné období.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return period;
|
||
|
||
}
|
||
|
||
|
||
function normalizePlanningTime_(
|
||
value
|
||
) {
|
||
|
||
value =
|
||
String(
|
||
value ||
|
||
''
|
||
).trim();
|
||
|
||
|
||
if (
|
||
!/^\d{1,2}:\d{2}$/.test(
|
||
value
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný čas.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const parts =
|
||
value.split(
|
||
':'
|
||
);
|
||
|
||
|
||
const hour =
|
||
Number(
|
||
parts[0]
|
||
);
|
||
|
||
|
||
const minute =
|
||
Number(
|
||
parts[1]
|
||
);
|
||
|
||
|
||
if (
|
||
hour < 0 ||
|
||
hour > 23 ||
|
||
minute < 0 ||
|
||
minute > 59
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný čas.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return (
|
||
String(
|
||
hour
|
||
).padStart(
|
||
2,
|
||
'0'
|
||
) +
|
||
':' +
|
||
String(
|
||
minute
|
||
).padStart(
|
||
2,
|
||
'0'
|
||
)
|
||
);
|
||
|
||
}
|
||
|
||
|
||
function planningNumber_(
|
||
value
|
||
) {
|
||
|
||
const number =
|
||
Number(
|
||
String(
|
||
value == null
|
||
? 0
|
||
: value
|
||
)
|
||
.replace(
|
||
',',
|
||
'.'
|
||
)
|
||
);
|
||
|
||
|
||
return isFinite(
|
||
number
|
||
)
|
||
? number
|
||
: 0;
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
VÝCHOZÍ PROVOZOVNA
|
||
============================================================ */
|
||
|
||
function getDefaultPlanningLocation_() {
|
||
|
||
const locations =
|
||
displayRows_(
|
||
CFG.SHEETS.LOCATIONS
|
||
);
|
||
|
||
|
||
if (
|
||
locations.length
|
||
) {
|
||
|
||
return String(
|
||
locations[0]
|
||
.location_id ||
|
||
''
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return 'LOCATION_MAIN';
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
VYTVOŘENÍ LISTŮ
|
||
|
||
Použije existující databázový spreadsheet.
|
||
============================================================ */
|
||
|
||
function ensureShiftPlanningSheet_(
|
||
sheetName,
|
||
headers
|
||
) {
|
||
|
||
let sheet;
|
||
|
||
|
||
try {
|
||
|
||
sheet =
|
||
sh_(
|
||
sheetName
|
||
);
|
||
|
||
|
||
if (
|
||
sheet
|
||
) {
|
||
|
||
return sheet;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
catch(error) {
|
||
|
||
/*
|
||
* list zatím neexistuje
|
||
*/
|
||
|
||
}
|
||
|
||
|
||
const spreadsheet =
|
||
getPlanningSpreadsheet_();
|
||
|
||
|
||
sheet =
|
||
spreadsheet
|
||
.getSheetByName(
|
||
sheetName
|
||
);
|
||
|
||
|
||
if (
|
||
!sheet
|
||
) {
|
||
|
||
sheet =
|
||
spreadsheet
|
||
.insertSheet(
|
||
sheetName
|
||
);
|
||
|
||
}
|
||
|
||
|
||
sheet
|
||
.getRange(
|
||
1,
|
||
1,
|
||
1,
|
||
headers.length
|
||
)
|
||
.setValues(
|
||
[
|
||
headers
|
||
]
|
||
);
|
||
|
||
|
||
sheet
|
||
.setFrozenRows(
|
||
1
|
||
);
|
||
|
||
|
||
return sheet;
|
||
|
||
}
|
||
|
||
|
||
function getPlanningSpreadsheet_() {
|
||
|
||
/*
|
||
* Nejprve zkusíme aktivní spreadsheet.
|
||
*/
|
||
|
||
const active =
|
||
SpreadsheetApp
|
||
.getActiveSpreadsheet();
|
||
|
||
|
||
if (
|
||
active
|
||
) {
|
||
|
||
return active;
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* Pokud je projekt standalone,
|
||
* hledáme ID ve Script Properties.
|
||
*/
|
||
|
||
const id =
|
||
PropertiesService
|
||
.getScriptProperties()
|
||
.getProperty(
|
||
'SPREADSHEET_ID'
|
||
);
|
||
|
||
|
||
if (
|
||
id
|
||
) {
|
||
|
||
return SpreadsheetApp
|
||
.openById(
|
||
id
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* Kompatibilita s případným CFG.SPREADSHEET_ID
|
||
*/
|
||
|
||
if (
|
||
typeof CFG !==
|
||
'undefined' &&
|
||
CFG.SPREADSHEET_ID
|
||
) {
|
||
|
||
return SpreadsheetApp
|
||
.openById(
|
||
CFG.SPREADSHEET_ID
|
||
);
|
||
|
||
}
|
||
|
||
|
||
throw new Error(
|
||
'Nepodařilo se zjistit databázový spreadsheet.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/* ============================================================
|
||
DIAGNOSTIKA
|
||
============================================================ */
|
||
|
||
function testShiftPlanning() {
|
||
|
||
Logger.log(
|
||
'SHIFT_SLOTS: ' +
|
||
JSON.stringify(
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SLOTS
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'SHIFT_SIGNUPS: ' +
|
||
JSON.stringify(
|
||
displayRows_(
|
||
SHIFT_PLAN_SHEETS.SIGNUPS
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
Logger.log(
|
||
'ShiftPlanningService OK'
|
||
);
|
||
|
||
}
|