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.
This commit is contained in:
Michal Pemcak
2026-08-16 18:27:50 +02:00
parent fffcb73ea4
commit f917ed06a8
64 changed files with 46554 additions and 553 deletions

View File

@@ -0,0 +1,131 @@
function clockAction(token, action) {
const user = requireUser_(token,['EMPLOYEE','MANAGER','ADMIN']);
if (!user.employee_id) throw new Error('Účet není propojený se zaměstnancem.');
return clockForEmployee_(user.employee_id, action, 'PORTAL', user.user_id);
}
function terminalClock(employeeId, pin, action, locationId) {
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
if (!emp || String(emp.terminal_pin) !== String(pin)) throw new Error('Neplatný PIN.');
if (!(emp.active === true || String(emp.active).toUpperCase() === 'TRUE')) throw new Error('Zaměstnanec není aktivní.');
return clockForEmployee_(employeeId, action, 'TERMINAL', 'TERMINAL:'+String(locationId || emp.location_id));
}
function clockForEmployee_(employeeId, action, source, createdBy) {
const allowed = ['CLOCK_IN','BREAK_START','BREAK_END','CLOCK_OUT'];
if (!allowed.includes(action)) throw new Error('Neplatná akce.');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const open = getOpenShift_(employeeId);
const t = now_();
if (action === 'CLOCK_IN') {
if (open) throw new Error('Směna už probíhá.');
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
const shift = {
shift_id:uuid_('SHIFT'),
employee_id:employeeId,
work_date:isoDate_(t),
clock_in:t,
clock_out:'',
break_minutes:0,
worked_minutes:0,
night_minutes:0,
weekend_minutes:0,
holiday_minutes:0,
status:'OPEN',
location_id:emp ? emp.location_id : '',
created_at:t,
updated_at:t
};
append_(CFG.SHEETS.SHIFTS,shift);
attendanceEvent_(employeeId,t,action,shift.location_id,source,createdBy);
return {ok:true,status:'OPEN',shift};
}
if (!open) throw new Error('Žádná otevřená směna.');
const events = rows_(CFG.SHEETS.ATTENDANCE_EVENTS)
.filter(e => String(e.employee_id) === String(employeeId) && new Date(e.timestamp) >= new Date(open.clock_in))
.sort((a,b) => new Date(a.timestamp)-new Date(b.timestamp));
const last = events[events.length-1];
if (action === 'BREAK_START' && last && String(last.event_type) === 'BREAK_START') throw new Error('Pauza už běží.');
if (action === 'BREAK_END' && (!last || String(last.event_type) !== 'BREAK_START')) throw new Error('Pauza neběží.');
if (action === 'CLOCK_OUT' && last && String(last.event_type) === 'BREAK_START') throw new Error('Nejdřív ukonči pauzu.');
attendanceEvent_(employeeId,t,action,open.location_id,source,createdBy);
if (action === 'CLOCK_OUT') {
const allEvents = events.concat([{timestamp:t,event_type:'CLOCK_OUT'}]);
const calc = calculateShift_(new Date(open.clock_in),t,allEvents);
updateBy_(CFG.SHEETS.SHIFTS,'shift_id',open.shift_id,{
clock_out:t,
break_minutes:calc.breakMinutes,
worked_minutes:calc.workedMinutes,
night_minutes:calc.nightMinutes,
weekend_minutes:calc.weekendMinutes,
status:'COMPLETED',
updated_at:t
});
return {ok:true,status:'COMPLETED',shift_id:open.shift_id,worked_minutes:calc.workedMinutes};
}
return {ok:true,status:action,shift_id:open.shift_id};
} finally {
lock.releaseLock();
}
}
function attendanceEvent_(employeeId,timestamp,type,locationId,source,createdBy) {
append_(CFG.SHEETS.ATTENDANCE_EVENTS,{
event_id:uuid_('EVT'),
employee_id:employeeId,
timestamp,
event_type:type,
location_id:locationId || '',
source:source || '',
created_by:createdBy || ''
});
}
function getOpenShift_(employeeId) {
const list = rows_(CFG.SHEETS.SHIFTS)
.filter(s => String(s.employee_id) === String(employeeId) && String(s.status) === 'OPEN')
.sort((a,b) => new Date(b.clock_in)-new Date(a.clock_in));
return list[0] || null;
}
function calculateShift_(clockIn, clockOut, events) {
let breakMinutes = 0, breakStart = null;
(events || []).forEach(e => {
if (String(e.event_type) === 'BREAK_START') breakStart = new Date(e.timestamp);
if (String(e.event_type) === 'BREAK_END' && breakStart) {
breakMinutes += Math.max(0, Math.round((new Date(e.timestamp)-breakStart)/60000));
breakStart = null;
}
});
const total = Math.max(0, Math.round((clockOut-clockIn)/60000));
const worked = Math.max(0,total-breakMinutes);
// Základní výpočet nočních minut 22:0006:00 po jednotlivých minutách.
let night = 0, weekend = 0;
let p = new Date(clockIn);
while (p < clockOut) {
const n = new Date(Math.min(p.getTime()+60000, clockOut.getTime()));
const hh = Number(Utilities.formatDate(p,CFG.TZ,'H'));
const dow = Number(Utilities.formatDate(p,CFG.TZ,'u'));
if (hh >= 22 || hh < 6) night += (n-p)/60000;
if (dow >= 6) weekend += (n-p)/60000;
p = n;
}
return {
breakMinutes:Math.round(breakMinutes),
workedMinutes:Math.round(worked),
nightMinutes:Math.round(night),
weekendMinutes:Math.round(weekend)
};
}