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:
131
gscript/AttendanceService.js
Normal file
131
gscript/AttendanceService.js
Normal 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: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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user