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

293 lines
5.9 KiB
JavaScript

function createEmployee(token, data) {
const admin = requireUser_(token,['ADMIN','MANAGER']);
const email = emailNorm_(data.email);
if (!email) throw new Error('E-mail je povinný.');
if (findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email)) {
throw new Error('Zaměstnanec s tímto e-mailem už existuje.');
}
const role = String(data.role || 'EMPLOYEE');
if (!['EMPLOYEE','MANAGER','ACCOUNTANT','ADMIN'].includes(role)) {
throw new Error('Neplatná role.');
}
const hourlyRate = data.hourly_rate === '' || data.hourly_rate === undefined
? ''
: Number(data.hourly_rate);
if (hourlyRate !== '' && (isNaN(hourlyRate) || hourlyRate < 0)) {
throw new Error('Hodinová sazba není platná.');
}
const pin = String(data.terminal_pin || '').trim();
if (pin && !/^\d{4,6}$/.test(pin)) {
throw new Error('PIN musí mít 4 až 6 číslic.');
}
const emp = {
employee_id: uuid_('EMP'),
first_name: String(data.first_name || '').trim(),
last_name: String(data.last_name || '').trim(),
email: email,
phone: String(data.phone || '').trim(),
company_id: String(data.company_id || 'COMPANY_MAIN'),
location_id: String(data.location_id || 'LOCATION_MAIN'),
position: String(data.position || '').trim(),
employment_type: String(data.employment_type || '').trim(),
terminal_pin: pin,
start_date: data.start_date || isoDate_(now_()),
end_date: '',
active: true
};
if (!emp.first_name || !emp.last_name) {
throw new Error('Jméno a příjmení jsou povinné.');
}
append_(CFG.SHEETS.EMPLOYEES, emp);
const user = {
user_id: uuid_('USR'),
email: email,
role: role,
employee_id: emp.employee_id,
password_salt: '',
password_hash: '',
active: true,
created_at: now_(),
last_login: ''
};
append_(CFG.SHEETS.USERS, user);
append_(CFG.SHEETS.USER_COMPANIES, {
user_id: user.user_id,
company_id: emp.company_id
});
if (hourlyRate !== '') {
append_(CFG.SHEETS.PAY_RATES, {
pay_rate_id: uuid_('RATE'),
employee_id: emp.employee_id,
valid_from: emp.start_date,
valid_to: '',
hourly_rate: hourlyRate,
created_by: admin.user_id,
created_at: now_()
});
}
audit_(admin.user_id,'EMPLOYEE_CREATED','EMPLOYEE',emp.employee_id,'',emp);
requestActivation(email);
return {
ok: true,
employee_id: emp.employee_id
};
}
function listEmployees(token) {
requireUser_(
token,
['ADMIN','MANAGER','ACCOUNTANT']
);
const employees =
rows_(CFG.SHEETS.EMPLOYEES);
const users =
rows_(CFG.SHEETS.USERS);
const rates =
rows_(CFG.SHEETS.PAY_RATES);
return employees.map(function(employee) {
const user =
users.find(function(u) {
return String(u.employee_id) ===
String(employee.employee_id);
});
const employeeRates =
rates.filter(function(rate) {
return String(rate.employee_id) ===
String(employee.employee_id);
});
employeeRates.sort(function(a,b) {
const dateA =
a.valid_from
? new Date(a.valid_from).getTime()
: 0;
const dateB =
b.valid_from
? new Date(b.valid_from).getTime()
: 0;
return dateB - dateA;
});
let hourlyRate = 0;
if (employeeRates.length > 0) {
hourlyRate =
Number(
employeeRates[0].hourly_rate || 0
);
}
const active =
employee.active === true ||
String(employee.active)
.toUpperCase() === 'TRUE';
return {
employee_id:
String(employee.employee_id || ''),
first_name:
String(employee.first_name || ''),
last_name:
String(employee.last_name || ''),
name:
(
String(employee.first_name || '') +
' ' +
String(employee.last_name || '')
).trim(),
email:
String(employee.email || ''),
phone:
String(employee.phone || ''),
company_id:
String(employee.company_id || ''),
location_id:
String(employee.location_id || ''),
position:
String(employee.position || ''),
employment_type:
String(employee.employment_type || ''),
terminal_pin:
String(employee.terminal_pin || ''),
start_date:
employee.start_date
? String(employee.start_date)
: '',
end_date:
employee.end_date
? String(employee.end_date)
: '',
active:
active,
role:
user
? String(user.role || 'EMPLOYEE')
: 'EMPLOYEE',
hourly_rate:
hourlyRate
};
});
}
function setEmployeeActive(token, employeeId, active) {
const admin = requireUser_(token,['ADMIN']);
const emp = findOne_(
CFG.SHEETS.EMPLOYEES,
r => String(r.employee_id) === String(employeeId)
);
if (!emp) throw new Error('Zaměstnanec nebyl nalezen.');
const user = findOne_(
CFG.SHEETS.USERS,
r => String(r.employee_id) === String(employeeId)
);
updateBy_(
CFG.SHEETS.EMPLOYEES,
'employee_id',
employeeId,
{
active: !!active,
end_date: active ? '' : isoDate_(now_())
}
);
if (user) {
updateBy_(
CFG.SHEETS.USERS,
'user_id',
user.user_id,
{ active: !!active }
);
}
audit_(
admin.user_id,
'EMPLOYEE_ACTIVE_CHANGED',
'EMPLOYEE',
employeeId,
emp.active,
!!active
);
return { ok: true };
}
function getCompaniesAndLocations(token) {
requireUser_(token,['ADMIN','MANAGER']);
return {
companies: rows_(CFG.SHEETS.COMPANIES)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
),
locations: rows_(CFG.SHEETS.LOCATIONS)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
)
};
}