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.
293 lines
5.9 KiB
JavaScript
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'
|
|
)
|
|
};
|
|
} |