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

131 lines
5.0 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.

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