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:
16
gscript/.clasp.json
Normal file
16
gscript/.clasp.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"scriptId": "1O3j68RUOqdnO4N2-sWgv5seES-Bd-AmZDE5HcDjgz0bTN6-jMwNiVrBj",
|
||||
"rootDir": "",
|
||||
"scriptExtensions": [
|
||||
".js",
|
||||
".gs"
|
||||
],
|
||||
"htmlExtensions": [
|
||||
".html"
|
||||
],
|
||||
"jsonExtensions": [
|
||||
".json"
|
||||
],
|
||||
"filePushOrder": [],
|
||||
"skipSubdirectories": false
|
||||
}
|
||||
1659
gscript/AdminService.js
Normal file
1659
gscript/AdminService.js
Normal file
File diff suppressed because it is too large
Load Diff
6
gscript/App.js
Normal file
6
gscript/App.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function doGet() {
|
||||
return HtmlService.createTemplateFromFile('Index')
|
||||
.evaluate()
|
||||
.setTitle(CFG.APP_NAME)
|
||||
.addMetaTag('viewport','width=device-width, initial-scale=1');
|
||||
}
|
||||
1344
gscript/AttendanceCorrectionAdminService.js
Normal file
1344
gscript/AttendanceCorrectionAdminService.js
Normal file
File diff suppressed because it is too large
Load Diff
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)
|
||||
};
|
||||
}
|
||||
1104
gscript/AuthService.js
Normal file
1104
gscript/AuthService.js
Normal file
File diff suppressed because it is too large
Load Diff
33
gscript/Automation.js
Normal file
33
gscript/Automation.js
Normal file
@@ -0,0 +1,33 @@
|
||||
function nightlyMaintenance() {
|
||||
const cutoff = Date.now() - 14*3600000;
|
||||
rows_(CFG.SHEETS.SHIFTS)
|
||||
.filter(s => String(s.status)==='OPEN' && new Date(s.clock_in).getTime() < cutoff)
|
||||
.forEach(s => {
|
||||
append_(CFG.SHEETS.NOTIFICATIONS,{
|
||||
notification_id:uuid_('NOT'),
|
||||
user_id:'',
|
||||
type:'OPEN_SHIFT_WARNING',
|
||||
title:'Pravděpodobně chybí odchod',
|
||||
message:'Směna '+s.shift_id+' je otevřená déle než 14 hodin.',
|
||||
url:'',
|
||||
read_at:'',
|
||||
created_at:now_(),
|
||||
email_sent_at:''
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function monthlyMaintenance() {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth()-1);
|
||||
const p = Utilities.formatDate(d,CFG.TZ,'yyyy-MM');
|
||||
rows_(CFG.SHEETS.EMPLOYEES)
|
||||
.filter(e => e.active === true || String(e.active).toUpperCase()==='TRUE')
|
||||
.forEach(e => {
|
||||
const exists = findOne_(CFG.SHEETS.MONTH_CLOSURES,r=>String(r.employee_id)===String(e.employee_id)&&String(r.period)===p);
|
||||
if (!exists) append_(CFG.SHEETS.MONTH_CLOSURES,{
|
||||
closure_id:uuid_('CLOSE'),employee_id:e.employee_id,period:p,
|
||||
employee_confirmed_at:'',manager_approved_at:'',manager_approved_by:'',locked_at:'',status:'WAITING_EMPLOYEE'
|
||||
});
|
||||
});
|
||||
}
|
||||
2048
gscript/ClosureService.js
Normal file
2048
gscript/ClosureService.js
Normal file
File diff suppressed because it is too large
Load Diff
295
gscript/Config.js
Normal file
295
gscript/Config.js
Normal file
@@ -0,0 +1,295 @@
|
||||
const CFG = Object.freeze({
|
||||
APP_NAME: 'EatMe Portál',
|
||||
TZ: 'Europe/Prague',
|
||||
SESSION_DAYS: 7,
|
||||
LOGIN_CODE_MINUTES: 15,
|
||||
PASSWORD_ROUNDS: 12000,
|
||||
|
||||
SHEETS: {
|
||||
USERS: 'USERS',
|
||||
EMPLOYEES: 'EMPLOYEES',
|
||||
COMPANIES: 'COMPANIES',
|
||||
LOCATIONS: 'LOCATIONS',
|
||||
USER_COMPANIES: 'USER_COMPANIES',
|
||||
PAY_RATES: 'PAY_RATES',
|
||||
ATTENDANCE_EVENTS: 'ATTENDANCE_EVENTS',
|
||||
SHIFTS: 'SHIFTS',
|
||||
SHIFT_CHANGE_REQUESTS: 'SHIFT_CHANGE_REQUESTS',
|
||||
SCHEDULES: 'SCHEDULES',
|
||||
REQUESTS: 'REQUESTS',
|
||||
MONTH_CLOSURES: 'MONTH_CLOSURES',
|
||||
PAYROLL: 'PAYROLL',
|
||||
PAYROLL_ITEMS: 'PAYROLL_ITEMS',
|
||||
PAYSLIPS: 'PAYSLIPS',
|
||||
NEWS: 'NEWS',
|
||||
NEWS_READS: 'NEWS_READS',
|
||||
DOCUMENTS: 'DOCUMENTS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
SESSIONS: 'SESSIONS',
|
||||
LOGIN_CODES: 'LOGIN_CODES',
|
||||
AUDIT_LOG: 'AUDIT_LOG',
|
||||
SETTINGS: 'SETTINGS'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const HEADERS = Object.freeze({
|
||||
|
||||
USERS: [
|
||||
'user_id',
|
||||
'email',
|
||||
'role',
|
||||
'employee_id',
|
||||
'password_salt',
|
||||
'password_hash',
|
||||
'active',
|
||||
'created_at',
|
||||
'last_login'
|
||||
],
|
||||
|
||||
EMPLOYEES: [
|
||||
'employee_id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'company_id',
|
||||
'location_id',
|
||||
'position',
|
||||
'employment_type',
|
||||
'terminal_pin',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'active'
|
||||
],
|
||||
|
||||
COMPANIES: [
|
||||
'company_id',
|
||||
'name',
|
||||
'ico',
|
||||
'active'
|
||||
],
|
||||
|
||||
LOCATIONS: [
|
||||
'location_id',
|
||||
'company_id',
|
||||
'name',
|
||||
'active'
|
||||
],
|
||||
|
||||
USER_COMPANIES: [
|
||||
'user_id',
|
||||
'company_id'
|
||||
],
|
||||
|
||||
PAY_RATES: [
|
||||
'pay_rate_id',
|
||||
'employee_id',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'hourly_rate',
|
||||
'created_by',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
ATTENDANCE_EVENTS: [
|
||||
'event_id',
|
||||
'employee_id',
|
||||
'timestamp',
|
||||
'event_type',
|
||||
'location_id',
|
||||
'source',
|
||||
'created_by'
|
||||
],
|
||||
|
||||
SHIFTS: [
|
||||
'shift_id',
|
||||
'employee_id',
|
||||
'work_date',
|
||||
'clock_in',
|
||||
'clock_out',
|
||||
'break_minutes',
|
||||
'worked_minutes',
|
||||
'night_minutes',
|
||||
'weekend_minutes',
|
||||
'holiday_minutes',
|
||||
'status',
|
||||
'location_id',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
],
|
||||
|
||||
SHIFT_CHANGE_REQUESTS: [
|
||||
'request_id',
|
||||
'shift_id',
|
||||
'employee_id',
|
||||
'field',
|
||||
'old_value',
|
||||
'requested_value',
|
||||
'reason',
|
||||
'status',
|
||||
'created_at',
|
||||
'resolved_at',
|
||||
'resolved_by'
|
||||
],
|
||||
|
||||
SCHEDULES: [
|
||||
'schedule_id',
|
||||
'employee_id',
|
||||
'location_id',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'status',
|
||||
'note',
|
||||
'created_by',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
REQUESTS: [
|
||||
'request_id',
|
||||
'employee_id',
|
||||
'type',
|
||||
'date_from',
|
||||
'date_to',
|
||||
'reason',
|
||||
'status',
|
||||
'created_at',
|
||||
'resolved_at',
|
||||
'resolved_by',
|
||||
'note'
|
||||
],
|
||||
|
||||
MONTH_CLOSURES: [
|
||||
'closure_id',
|
||||
'employee_id',
|
||||
'period',
|
||||
'employee_confirmed_at',
|
||||
'manager_approved_at',
|
||||
'manager_approved_by',
|
||||
'locked_at',
|
||||
'status'
|
||||
],
|
||||
|
||||
PAYROLL: [
|
||||
'payroll_id',
|
||||
'employee_id',
|
||||
'period',
|
||||
'approved_minutes',
|
||||
'base_amount',
|
||||
'bonus_amount',
|
||||
'tips_amount',
|
||||
'other_amount',
|
||||
'final_amount',
|
||||
'status',
|
||||
'payment_date',
|
||||
'updated_at'
|
||||
],
|
||||
|
||||
PAYROLL_ITEMS: [
|
||||
'item_id',
|
||||
'payroll_id',
|
||||
'employee_id',
|
||||
'period',
|
||||
'type',
|
||||
'quantity',
|
||||
'rate',
|
||||
'amount',
|
||||
'note',
|
||||
'created_by',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
PAYSLIPS: [
|
||||
'payslip_id',
|
||||
'employee_id',
|
||||
'period',
|
||||
'drive_file_id',
|
||||
'file_name',
|
||||
'uploaded_at',
|
||||
'uploaded_by'
|
||||
],
|
||||
|
||||
NEWS: [
|
||||
'news_id',
|
||||
'title',
|
||||
'content',
|
||||
'category',
|
||||
'company_id',
|
||||
'location_id',
|
||||
'target_role',
|
||||
'published_from',
|
||||
'published_to',
|
||||
'require_confirmation',
|
||||
'active',
|
||||
'created_by',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
NEWS_READS: [
|
||||
'news_read_id',
|
||||
'news_id',
|
||||
'user_id',
|
||||
'read_at',
|
||||
'confirmed_at'
|
||||
],
|
||||
|
||||
DOCUMENTS: [
|
||||
'document_id',
|
||||
'employee_id',
|
||||
'company_id',
|
||||
'type',
|
||||
'title',
|
||||
'drive_file_id',
|
||||
'visible_to_employee',
|
||||
'uploaded_at',
|
||||
'uploaded_by'
|
||||
],
|
||||
|
||||
NOTIFICATIONS: [
|
||||
'notification_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'title',
|
||||
'message',
|
||||
'url',
|
||||
'read_at',
|
||||
'created_at',
|
||||
'email_sent_at'
|
||||
],
|
||||
|
||||
SESSIONS: [
|
||||
'session_id',
|
||||
'user_id',
|
||||
'token_hash',
|
||||
'expires_at',
|
||||
'created_at',
|
||||
'last_seen_at'
|
||||
],
|
||||
|
||||
LOGIN_CODES: [
|
||||
'code_id',
|
||||
'email',
|
||||
'purpose',
|
||||
'code_hash',
|
||||
'expires_at',
|
||||
'used_at',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
AUDIT_LOG: [
|
||||
'audit_id',
|
||||
'user_id',
|
||||
'action',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'created_at'
|
||||
],
|
||||
|
||||
SETTINGS: [
|
||||
'key',
|
||||
'value'
|
||||
]
|
||||
|
||||
});
|
||||
3278
gscript/DailySalesService.js
Normal file
3278
gscript/DailySalesService.js
Normal file
File diff suppressed because it is too large
Load Diff
1099
gscript/DashboardService.js
Normal file
1099
gscript/DashboardService.js
Normal file
File diff suppressed because it is too large
Load Diff
72
gscript/Database.js
Normal file
72
gscript/Database.js
Normal file
@@ -0,0 +1,72 @@
|
||||
function db_() {
|
||||
const id = PropertiesService.getScriptProperties().getProperty('DB_SPREADSHEET_ID');
|
||||
if (!id) throw new Error('Databáze není inicializovaná. Spusť setupSystem().');
|
||||
return SpreadsheetApp.openById(id);
|
||||
}
|
||||
|
||||
function sh_(name) {
|
||||
const s = db_().getSheetByName(name);
|
||||
if (!s) throw new Error('Chybí list ' + name);
|
||||
return s;
|
||||
}
|
||||
|
||||
function rows_(name) {
|
||||
const s = sh_(name);
|
||||
const values = s.getDataRange().getValues();
|
||||
if (values.length < 2) return [];
|
||||
const headers = values[0].map(String);
|
||||
return values.slice(1).filter(r => r.some(v => v !== '')).map(r => {
|
||||
const o = {};
|
||||
headers.forEach((h, i) => o[h] = r[i]);
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
function append_(name, obj) {
|
||||
const s = sh_(name);
|
||||
const headers = s.getRange(1,1,1,s.getLastColumn()).getValues()[0];
|
||||
s.appendRow(headers.map(h => obj[h] !== undefined ? obj[h] : ''));
|
||||
return obj;
|
||||
}
|
||||
|
||||
function updateBy_(name, key, value, patch) {
|
||||
const s = sh_(name);
|
||||
const data = s.getDataRange().getValues();
|
||||
if (!data.length) return false;
|
||||
const headers = data[0].map(String);
|
||||
const keyCol = headers.indexOf(key);
|
||||
if (keyCol < 0) throw new Error('Sloupec ' + key + ' neexistuje v ' + name);
|
||||
const row = data.slice(1).findIndex(r => String(r[keyCol]) === String(value));
|
||||
if (row < 0) return false;
|
||||
Object.keys(patch).forEach(k => {
|
||||
const c = headers.indexOf(k);
|
||||
if (c >= 0) s.getRange(row + 2, c + 1).setValue(patch[k]);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function findOne_(name, predicate) {
|
||||
return rows_(name).find(predicate) || null;
|
||||
}
|
||||
|
||||
function uuid_(prefix) {
|
||||
return (prefix || 'ID') + '_' + Utilities.getUuid().replace(/-/g,'').slice(0,16).toUpperCase();
|
||||
}
|
||||
|
||||
function now_() { return new Date(); }
|
||||
|
||||
function isoDate_(d) {
|
||||
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
function period_(d) {
|
||||
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM');
|
||||
}
|
||||
|
||||
function emailNorm_(email) {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function json_(v) {
|
||||
try { return JSON.stringify(v); } catch(e) { return String(v); }
|
||||
}
|
||||
1175
gscript/EmployeeEditService.js
Normal file
1175
gscript/EmployeeEditService.js
Normal file
File diff suppressed because it is too large
Load Diff
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'
|
||||
)
|
||||
};
|
||||
}
|
||||
18711
gscript/Index.html
Normal file
18711
gscript/Index.html
Normal file
File diff suppressed because one or more lines are too long
15
gscript/LoginPerformanceService.js
Normal file
15
gscript/LoginPerformanceService.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/* ============================================================
|
||||
EATME PORTÁL – LOGIN PERFORMANCE SERVICE
|
||||
============================================================ */
|
||||
|
||||
function loginWithUser(
|
||||
email,
|
||||
password
|
||||
) {
|
||||
|
||||
return login(
|
||||
email,
|
||||
password
|
||||
);
|
||||
|
||||
}
|
||||
55
gscript/NewsService.js
Normal file
55
gscript/NewsService.js
Normal file
@@ -0,0 +1,55 @@
|
||||
function getNewsForUser_(user, emp) {
|
||||
const now = now_();
|
||||
const memberships = rows_(CFG.SHEETS.USER_COMPANIES)
|
||||
.filter(x => String(x.user_id) === String(user.user_id))
|
||||
.map(x => String(x.company_id));
|
||||
|
||||
return rows_(CFG.SHEETS.NEWS)
|
||||
.filter(n => n.active === true || String(n.active).toUpperCase() === 'TRUE')
|
||||
.filter(n => !n.published_from || new Date(n.published_from) <= now)
|
||||
.filter(n => !n.published_to || new Date(n.published_to) >= now)
|
||||
.filter(n => !n.target_role || String(n.target_role) === 'ALL' || String(n.target_role) === String(user.role))
|
||||
.filter(n => !n.company_id || memberships.includes(String(n.company_id)) || (emp && String(emp.company_id) === String(n.company_id)))
|
||||
.filter(n => !n.location_id || (emp && String(emp.location_id) === String(n.location_id)))
|
||||
.sort((a,b)=>new Date(b.created_at)-new Date(a.created_at))
|
||||
.map(n => ({
|
||||
news_id:n.news_id,title:n.title,content:n.content,category:n.category,
|
||||
require_confirmation:n.require_confirmation,created_at:n.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
function publishNews(token, data) {
|
||||
const user = requireUser_(token,['ADMIN','MANAGER']);
|
||||
const n = {
|
||||
news_id:uuid_('NEWS'),
|
||||
title:String(data.title || '').trim(),
|
||||
content:String(data.content || '').trim(),
|
||||
category:String(data.category || 'INFO'),
|
||||
company_id:String(data.company_id || ''),
|
||||
location_id:String(data.location_id || ''),
|
||||
target_role:String(data.target_role || 'ALL'),
|
||||
published_from:data.published_from || now_(),
|
||||
published_to:data.published_to || '',
|
||||
require_confirmation:!!data.require_confirmation,
|
||||
active:true,
|
||||
created_by:user.user_id,
|
||||
created_at:now_()
|
||||
};
|
||||
if (!n.title) throw new Error('Titulek je povinný.');
|
||||
append_(CFG.SHEETS.NEWS,n);
|
||||
audit_(user.user_id,'NEWS_PUBLISHED','NEWS',n.news_id,'',n);
|
||||
return {ok:true,news_id:n.news_id};
|
||||
}
|
||||
|
||||
function confirmNews(token, newsId) {
|
||||
const user = requireUser_(token);
|
||||
let r = findOne_(CFG.SHEETS.NEWS_READS, x => String(x.news_id)===String(newsId) && String(x.user_id)===String(user.user_id));
|
||||
if (r) {
|
||||
updateBy_(CFG.SHEETS.NEWS_READS,'news_read_id',r.news_read_id,{read_at:r.read_at || now_(),confirmed_at:now_()});
|
||||
} else {
|
||||
append_(CFG.SHEETS.NEWS_READS,{
|
||||
news_read_id:uuid_('NREAD'),news_id:newsId,user_id:user.user_id,read_at:now_(),confirmed_at:now_()
|
||||
});
|
||||
}
|
||||
return {ok:true};
|
||||
}
|
||||
1575
gscript/NewsService_Roles.js
Normal file
1575
gscript/NewsService_Roles.js
Normal file
File diff suppressed because it is too large
Load Diff
1554
gscript/PayrollService.js
Normal file
1554
gscript/PayrollService.js
Normal file
File diff suppressed because it is too large
Load Diff
377
gscript/Security.js
Normal file
377
gscript/Security.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/* ============================================================
|
||||
EATME PORTÁL – SECURITY HELPERS
|
||||
============================================================ */
|
||||
|
||||
|
||||
/* ============================================================
|
||||
NÁHODNÝ AKTIVAČNÍ KÓD
|
||||
============================================================ */
|
||||
|
||||
function randomCode_() {
|
||||
|
||||
return String(
|
||||
Math.floor(
|
||||
100000 +
|
||||
Math.random() *
|
||||
900000
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SESSION TOKEN
|
||||
============================================================ */
|
||||
|
||||
function randomToken_() {
|
||||
|
||||
return Utilities
|
||||
.base64EncodeWebSafe(
|
||||
Utilities.computeDigest(
|
||||
Utilities.DigestAlgorithm.SHA_256,
|
||||
Utilities.getUuid() +
|
||||
':' +
|
||||
new Date().getTime() +
|
||||
':' +
|
||||
Math.random()
|
||||
)
|
||||
)
|
||||
.replace(
|
||||
/=+$/,
|
||||
''
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SHA-256
|
||||
============================================================ */
|
||||
|
||||
function hashText_(text) {
|
||||
|
||||
const bytes =
|
||||
Utilities.computeDigest(
|
||||
Utilities.DigestAlgorithm.SHA_256,
|
||||
String(
|
||||
text
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
return bytes
|
||||
.map(
|
||||
function(byte) {
|
||||
|
||||
return (
|
||||
'0' +
|
||||
(
|
||||
(
|
||||
byte < 0
|
||||
? byte + 256
|
||||
: byte
|
||||
)
|
||||
.toString(
|
||||
16
|
||||
)
|
||||
)
|
||||
).slice(
|
||||
-2
|
||||
);
|
||||
|
||||
}
|
||||
)
|
||||
.join(
|
||||
''
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PASSWORD HASH
|
||||
|
||||
POZOR:
|
||||
Toto je původní algoritmus.
|
||||
Neměníme jej, aby fungovala současná hesla.
|
||||
============================================================ */
|
||||
|
||||
function passwordHash_(
|
||||
password,
|
||||
salt
|
||||
) {
|
||||
|
||||
const pepper =
|
||||
PropertiesService
|
||||
.getScriptProperties()
|
||||
.getProperty(
|
||||
'PASSWORD_PEPPER'
|
||||
) ||
|
||||
'';
|
||||
|
||||
|
||||
let value =
|
||||
String(
|
||||
password
|
||||
) +
|
||||
'|' +
|
||||
salt +
|
||||
'|' +
|
||||
pepper;
|
||||
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < CFG.PASSWORD_ROUNDS;
|
||||
i++
|
||||
) {
|
||||
|
||||
value =
|
||||
hashText_(
|
||||
value +
|
||||
'|' +
|
||||
i
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
return value;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
VALIDACE HESLA
|
||||
============================================================ */
|
||||
|
||||
function validatePassword_(password) {
|
||||
|
||||
const value =
|
||||
String(
|
||||
password ||
|
||||
''
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
value.length <
|
||||
10
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Heslo musí mít alespoň 10 znaků.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
!/[A-Za-z]/.test(
|
||||
value
|
||||
) ||
|
||||
!/[0-9]/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Heslo musí obsahovat písmeno a číslo.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
OVĚŘENÍ UŽIVATELE
|
||||
|
||||
Optimalizace:
|
||||
už při každém requestu NEZAPISUJEME last_seen_at.
|
||||
============================================================ */
|
||||
|
||||
function requireUser_(
|
||||
token,
|
||||
roles
|
||||
) {
|
||||
|
||||
const tokenHash =
|
||||
hashText_(
|
||||
String(
|
||||
token ||
|
||||
''
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
const session =
|
||||
findOne_(
|
||||
CFG.SHEETS.SESSIONS,
|
||||
function(row) {
|
||||
|
||||
return (
|
||||
|
||||
String(
|
||||
row.token_hash
|
||||
) ===
|
||||
tokenHash &&
|
||||
|
||||
new Date(
|
||||
row.expires_at
|
||||
).getTime() >
|
||||
Date.now()
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
!session
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Přihlášení vypršelo. Přihlas se znovu.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
const user =
|
||||
findOne_(
|
||||
CFG.SHEETS.USERS,
|
||||
function(row) {
|
||||
|
||||
return (
|
||||
String(
|
||||
row.user_id
|
||||
) ===
|
||||
String(
|
||||
session.user_id
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
!user ||
|
||||
!(
|
||||
user.active ===
|
||||
true ||
|
||||
|
||||
String(
|
||||
user.active
|
||||
).toUpperCase() ===
|
||||
'TRUE'
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Účet není aktivní.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
roles &&
|
||||
roles.length &&
|
||||
!roles.includes(
|
||||
String(
|
||||
user.role
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Nemáš oprávnění.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* PŮVODNĚ ZDE BYLO:
|
||||
*
|
||||
* updateBy_(
|
||||
* CFG.SHEETS.SESSIONS,
|
||||
* 'session_id',
|
||||
* session.session_id,
|
||||
* {last_seen_at:now_()}
|
||||
* );
|
||||
*
|
||||
* To způsobovalo zápis do Google Sheets při téměř
|
||||
* každém kliknutí v aplikaci.
|
||||
*/
|
||||
|
||||
|
||||
return user;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
AUDIT
|
||||
============================================================ */
|
||||
|
||||
function audit_(
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
oldValue,
|
||||
newValue
|
||||
) {
|
||||
|
||||
append_(
|
||||
CFG.SHEETS.AUDIT_LOG,
|
||||
{
|
||||
|
||||
audit_id:
|
||||
uuid_(
|
||||
'AUD'
|
||||
),
|
||||
|
||||
user_id:
|
||||
userId ||
|
||||
'SYSTEM',
|
||||
|
||||
action:
|
||||
action,
|
||||
|
||||
entity_type:
|
||||
entityType ||
|
||||
'',
|
||||
|
||||
entity_id:
|
||||
entityId ||
|
||||
'',
|
||||
|
||||
old_value:
|
||||
json_(
|
||||
oldValue
|
||||
),
|
||||
|
||||
new_value:
|
||||
json_(
|
||||
newValue
|
||||
),
|
||||
|
||||
created_at:
|
||||
now_()
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
128
gscript/Setup.js
Normal file
128
gscript/Setup.js
Normal file
@@ -0,0 +1,128 @@
|
||||
function setupSystem() {
|
||||
const props = PropertiesService.getScriptProperties();
|
||||
let id = props.getProperty('DB_SPREADSHEET_ID');
|
||||
let ss;
|
||||
|
||||
if (id) {
|
||||
ss = SpreadsheetApp.openById(id);
|
||||
} else {
|
||||
ss = SpreadsheetApp.create('MeStaff Database');
|
||||
props.setProperty('DB_SPREADSHEET_ID', ss.getId());
|
||||
}
|
||||
|
||||
Object.keys(CFG.SHEETS).forEach(k => {
|
||||
const name = CFG.SHEETS[k];
|
||||
let s = ss.getSheetByName(name);
|
||||
if (!s) s = ss.insertSheet(name);
|
||||
const headers = HEADERS[k];
|
||||
if (s.getLastRow() === 0) s.getRange(1,1,1,headers.length).setValues([headers]);
|
||||
s.setFrozenRows(1);
|
||||
s.getRange(1,1,1,headers.length).setFontWeight('bold');
|
||||
});
|
||||
|
||||
const defaultSheet = ss.getSheetByName('Sheet1') || ss.getSheetByName('List1');
|
||||
if (defaultSheet && Object.values(CFG.SHEETS).indexOf(defaultSheet.getName()) === -1) {
|
||||
ss.deleteSheet(defaultSheet);
|
||||
}
|
||||
|
||||
if (!props.getProperty('PASSWORD_PEPPER')) {
|
||||
props.setProperty('PASSWORD_PEPPER', Utilities.getUuid() + Utilities.getUuid());
|
||||
}
|
||||
|
||||
seedSettings_();
|
||||
return {ok:true, spreadsheetId:ss.getId(), url:ss.getUrl()};
|
||||
}
|
||||
|
||||
function seedSettings_() {
|
||||
const current = rows_(CFG.SHEETS.SETTINGS);
|
||||
const map = {};
|
||||
current.forEach(r => map[r.key] = r.value);
|
||||
const defaults = {
|
||||
APP_NAME: 'MeStaff',
|
||||
CURRENCY: 'CZK',
|
||||
TIMEZONE: CFG.TZ,
|
||||
NIGHT_START: '22:00',
|
||||
NIGHT_END: '06:00'
|
||||
};
|
||||
Object.keys(defaults).forEach(k => {
|
||||
if (map[k] === undefined) append_(CFG.SHEETS.SETTINGS, {key:k, value:defaults[k]});
|
||||
});
|
||||
}
|
||||
|
||||
function seedFirstAdmin(email, firstName, lastName) {
|
||||
email = emailNorm_(email);
|
||||
if (!email) throw new Error('Zadej e-mail.');
|
||||
const lock = LockService.getScriptLock();
|
||||
lock.waitLock(10000);
|
||||
try {
|
||||
let company = findOne_(CFG.SHEETS.COMPANIES, r => r.active === true || String(r.active).toUpperCase() === 'TRUE');
|
||||
if (!company) {
|
||||
company = {company_id:'COMPANY_MAIN', name:'Hlavní společnost', ico:'', active:true};
|
||||
append_(CFG.SHEETS.COMPANIES, company);
|
||||
}
|
||||
let loc = findOne_(CFG.SHEETS.LOCATIONS, r => String(r.company_id) === String(company.company_id));
|
||||
if (!loc) {
|
||||
loc = {location_id:'LOCATION_MAIN', company_id:company.company_id, name:'Hlavní provozovna', active:true};
|
||||
append_(CFG.SHEETS.LOCATIONS, loc);
|
||||
}
|
||||
let emp = findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email);
|
||||
if (!emp) {
|
||||
emp = {
|
||||
employee_id: uuid_('EMP'),
|
||||
first_name:firstName || 'Admin',
|
||||
last_name:lastName || '',
|
||||
email,
|
||||
company_id:company.company_id,
|
||||
location_id:loc.location_id,
|
||||
position:'Administrátor',
|
||||
employment_type:'',
|
||||
terminal_pin:'',
|
||||
start_date:isoDate_(now_()),
|
||||
end_date:'',
|
||||
active:true
|
||||
};
|
||||
append_(CFG.SHEETS.EMPLOYEES, emp);
|
||||
}
|
||||
let user = findOne_(CFG.SHEETS.USERS, r => emailNorm_(r.email) === email);
|
||||
if (!user) {
|
||||
user = {
|
||||
user_id:uuid_('USR'),
|
||||
email,
|
||||
role:'ADMIN',
|
||||
employee_id:emp.employee_id,
|
||||
password_salt:'',
|
||||
password_hash:'',
|
||||
active:true,
|
||||
created_at:now_(),
|
||||
last_login:''
|
||||
};
|
||||
append_(CFG.SHEETS.USERS, user);
|
||||
}
|
||||
return {ok:true, email, message:'Admin založen. Teď použij „Aktivovat účet“ na přihlašovací stránce.'};
|
||||
} finally {
|
||||
lock.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function createSystemTriggers() {
|
||||
ScriptApp.getProjectTriggers().forEach(t => {
|
||||
if (['nightlyMaintenance','monthlyMaintenance'].includes(t.getHandlerFunction())) {
|
||||
ScriptApp.deleteTrigger(t);
|
||||
}
|
||||
});
|
||||
|
||||
ScriptApp.newTrigger('nightlyMaintenance')
|
||||
.timeBased().everyDays(1).atHour(3).inTimezone(CFG.TZ).create();
|
||||
|
||||
ScriptApp.newTrigger('monthlyMaintenance')
|
||||
.timeBased().onMonthDay(1).atHour(4).inTimezone(CFG.TZ).create();
|
||||
|
||||
return {ok:true};
|
||||
}
|
||||
function createMyAdmin() {
|
||||
return seedFirstAdmin(
|
||||
'filip.thurrigl@gmail.com',
|
||||
'Philipp',
|
||||
'Thürrigl'
|
||||
);
|
||||
}
|
||||
2866
gscript/ShiftPlanningService.js
Normal file
2866
gscript/ShiftPlanningService.js
Normal file
File diff suppressed because it is too large
Load Diff
1591
gscript/ShiftReconciliationService.js
Normal file
1591
gscript/ShiftReconciliationService.js
Normal file
File diff suppressed because it is too large
Load Diff
3605
gscript/WeeklyPayrollService.js
Normal file
3605
gscript/WeeklyPayrollService.js
Normal file
File diff suppressed because it is too large
Load Diff
10
gscript/appsscript.json
Normal file
10
gscript/appsscript.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"timeZone": "Europe/Prague",
|
||||
"dependencies": {},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8",
|
||||
"webapp": {
|
||||
"executeAs": "USER_DEPLOYING",
|
||||
"access": "ANYONE_ANONYMOUS"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user