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.
131 lines
5.0 KiB
JavaScript
131 lines
5.0 KiB
JavaScript
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:00–06: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)
|
||
};
|
||
} |