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

2867 lines
36 KiB
JavaScript
Raw 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.

/* ============================================================
MESTAFF SHIFT PLANNING SERVICE
Plánované směny jsou oddělené od skutečné docházky.
Výchozí pravidla:
NEČT 18:0023:00, kapacita 1
SO 18:0002:00, kapacita 1
20:0000: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:0018: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 = 112
============================================================ */
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 011
============================================================ */
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'
);
}