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:
293
gscript/EmployeeService.js
Normal file
293
gscript/EmployeeService.js
Normal file
@@ -0,0 +1,293 @@
|
||||
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'
|
||||
)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user