// =============================================
// Recycle Object — Settings Page
// + Employee management for Telegram bot
// =============================================
const Settings = {
currentTab: 'production',
employeesData: [],
editingEmployeeId: null,
authAccountsData: [],
authActivityData: [],
authSessionsData: [],
timeEntriesData: [],
employeeAuthAudit: null,
editingAuthAccountId: null,
lastIssuedAuthCredentials: null,
// Tabs that require admin access
ADMIN_TABS: new Set(['indirect', 'costs', 'logins', 'sessions', 'backup']),
PAYROLL_PROFILE_LABELS: {
hourly: 'Почасовая',
salary_monthly: 'Оклад за месяц',
salary_semimonth_threshold: 'Оклад 60 + 60',
management_salary_with_production_allocation: 'Оклад + факт. часы в производство',
},
async load() {
this._applyAdminVisibility();
this.populateFields();
},
_applyAdminVisibility() {
const admin = App.isAdmin();
// Hide/show admin-only tabs in the tab bar
document.querySelectorAll('#page-settings .tabs .tab').forEach(el => {
const tab = el.dataset.tab;
if (this.ADMIN_TABS.has(tab)) {
el.style.display = admin ? '' : 'none';
}
});
// In the employees tab, hide salary columns for non-admin
// (handled in _renderEmployeesList and _renderEditForm)
},
switchTab(tab) {
// Block non-admin from accessing restricted tabs
if (this.ADMIN_TABS.has(tab) && !App.isAdmin()) {
App.toast('Доступ ограничен');
return;
}
this.currentTab = tab;
document.querySelectorAll('.settings-tab-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.tabs .tab').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tab);
});
const target = document.getElementById('settings-tab-' + tab);
if (target) target.style.display = '';
// Lazy-load employees when tab is opened
if (tab === 'employees' && this.employeesData.length === 0) {
this.loadEmployeesTab();
}
if (tab === 'indirect') {
IndirectCosts.load();
}
if (tab === 'logins') {
this.loadLoginsTab();
}
if (tab === 'sessions') {
this.loadSessionsTab();
}
// Load backup tab info
if (tab === 'backup') {
this.loadBackupTab();
}
// Load timing editor
if (tab === 'timing') {
this.loadTimingTab();
}
},
populateFields() {
const s = App.settings;
if (!s) return;
// Fill all numeric inputs with data-key attribute
document.querySelectorAll('[data-key]').forEach(input => {
const key = input.dataset.key;
if (s[key] !== undefined) {
input.value = s[key];
}
});
// Fill all text inputs with data-key-text attribute
document.querySelectorAll('[data-key-text]').forEach(input => {
const key = input.dataset.keyText;
if (s[key] !== undefined) {
input.value = s[key];
}
});
const yFrom = document.getElementById('set-holidays-year-from');
const yTo = document.getElementById('set-holidays-year-to');
const nowYear = new Date().getFullYear();
if (yFrom && !yFrom.value) yFrom.value = String(nowYear);
if (yTo && !yTo.value) yTo.value = String(nowYear + 1);
this.updateProductionHints();
},
_rfProdCalendarHolidaysByYear(year) {
// Official production calendars (non-working holiday dates incl. transfer days)
// Sources: consultant production calendars for 2024/2025/2026.
const m = {
2024: [
'2024-01-01','2024-01-02','2024-01-03','2024-01-04','2024-01-05','2024-01-06','2024-01-07','2024-01-08',
'2024-02-23','2024-03-08',
'2024-04-29','2024-04-30',
'2024-05-01','2024-05-09','2024-05-10',
'2024-06-12','2024-11-04',
'2024-12-30','2024-12-31',
],
2025: [
'2025-01-01','2025-01-02','2025-01-03','2025-01-04','2025-01-05','2025-01-06','2025-01-07','2025-01-08',
'2025-02-23','2025-03-08',
'2025-05-01','2025-05-02','2025-05-08','2025-05-09',
'2025-06-12','2025-06-13',
'2025-11-03','2025-11-04',
'2025-12-31',
],
2026: [
'2026-01-01','2026-01-02','2026-01-03','2026-01-04','2026-01-05','2026-01-06','2026-01-07','2026-01-08','2026-01-09',
'2026-02-23','2026-03-08','2026-03-09',
'2026-05-01','2026-05-09','2026-05-11',
'2026-06-12','2026-11-04',
'2026-12-31',
],
};
return m[year] ? [...m[year]] : null;
},
_rfStatutoryFallbackByYear(year) {
const d = (mmdd) => `${year}-${mmdd}`;
return [
d('01-01'), d('01-02'), d('01-03'), d('01-04'),
d('01-05'), d('01-06'), d('01-07'), d('01-08'),
d('02-23'), d('03-08'),
d('05-01'), d('05-09'),
d('06-12'), d('11-04'),
];
},
async autofillProductionHolidaysRf() {
const fromEl = document.getElementById('set-holidays-year-from');
const toEl = document.getElementById('set-holidays-year-to');
const inputEl = document.getElementById('set-production_holidays');
if (!fromEl || !toEl || !inputEl) return;
let from = parseInt(fromEl.value, 10);
let to = parseInt(toEl.value, 10);
if (!Number.isFinite(from) || !Number.isFinite(to)) {
App.toast('Укажите корректный диапазон лет');
return;
}
if (from > to) [from, to] = [to, from];
if (to - from > 20) {
App.toast('Слишком большой диапазон лет (макс. 20)');
return;
}
const allDates = [];
const fallbackYears = [];
for (let y = from; y <= to; y++) {
const known = this._rfProdCalendarHolidaysByYear(y);
if (known && known.length > 0) {
allDates.push(...known);
} else {
fallbackYears.push(y);
allDates.push(...this._rfStatutoryFallbackByYear(y));
}
}
const uniqueSorted = Array.from(new Set(allDates))
.filter(v => /^\d{4}-\d{2}-\d{2}$/.test(v))
.sort();
const value = uniqueSorted.join(', ');
inputEl.value = value;
App.settings = App.settings || {};
App.settings.production_holidays = value;
await saveSetting('production_holidays', value);
if (fallbackYears.length > 0) {
App.toast(`Праздники заполнены. Для ${fallbackYears.join(', ')} использован базовый набор ст.112 ТК РФ (без переносов).`);
} else {
App.toast('Праздничные даты заполнены из производственного календаря РФ и сохранены');
}
},
updateProductionHints() {
const readNum = (id, fallback = 0) => {
const el = document.getElementById(id);
if (!el) return fallback;
const v = parseFloat(el.value);
return Number.isFinite(v) ? v : fallback;
};
const setHint = (id, text) => {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
const pct = (v) => `${Math.round((v || 0) * 1000) / 10}%`;
const hoursPerWorker = readNum('set-hours_per_worker', 168);
const workLoad = readNum('set-work_load_ratio', 0.8);
const planningWorkers = readNum('set-planning_workers_count', 2);
const planningHoursPerDay = readNum('set-planning_hours_per_day', 8);
const plasticRatio = readNum('set-plastic_injection_ratio', 0.7);
const packagingRatio = readNum('set-packaging_ratio', 0.3);
const wasteFactor = readNum('set-waste_factor', 1.1);
setHint('set-hours-per-worker-hint', `ч/мес (сейчас: ${hoursPerWorker})`);
setHint('set-work-load-hint', `${workLoad} = ${pct(workLoad)}`);
setHint('set-planning-workers-hint', `${planningWorkers} чел для реального плана, отдельно от pricing`);
setHint('set-planning-hours-hint', `${planningHoursPerDay} ч/день в календаре`);
setHint('set-plastic-ratio-hint', `${plasticRatio} = ${pct(plasticRatio)}`);
setHint('set-packaging-ratio-hint', `${packagingRatio} = ${pct(packagingRatio)}`);
const wastePct = Math.round((wasteFactor - 1) * 1000) / 10;
const wasteSign = wastePct >= 0 ? '+' : '';
setHint('set-waste-factor-hint', `${wasteFactor} = ${wasteSign}${wastePct}% к времени/себестоимости`);
setHint('set-planning-capacity-summary', `Календарь считает ${planningWorkers * planningHoursPerDay} ч/день как реальную мощность цеха. Цены и калькулятор остаются на своих pricing-параметрах.`);
},
async saveAll() {
const newSettings = { ...App.settings };
document.querySelectorAll('[data-key]').forEach(input => {
const key = input.dataset.key;
newSettings[key] = parseFloat(input.value) || 0;
});
// Save text fields (company legal details etc.)
document.querySelectorAll('[data-key-text]').forEach(input => {
const key = input.dataset.keyText;
newSettings[key] = input.value.trim();
});
await saveAllSettings(newSettings);
// Update app state
App.settings = newSettings;
App.params = getProductionParams(newSettings);
// Recalculate if calculator has data
if (Calculator && Calculator.items && Calculator.items.length > 0) {
Calculator.recalculate();
}
App.toast('Настройки сохранены');
},
// ==========================================
// EMPLOYEES — Management for Telegram bot
// ==========================================
async loadEmployeesTab() {
try {
this.employeesData = await loadEmployees();
} catch (err) {
console.error('loadEmployeesTab error:', err);
this.employeesData = [];
}
if (!this.employeesData || !Array.isArray(this.employeesData)) {
this.employeesData = [];
}
this.renderEmployeesTable();
try {
this.authAccountsData = await loadAuthAccounts();
} catch (err) {
console.error('loadEmployeesTab auth load error:', err);
this.authAccountsData = [];
}
if (!Array.isArray(this.authAccountsData)) {
this.authAccountsData = [];
}
this.renderEmployeesTable();
},
renderEmployeesTable() {
const tbody = document.getElementById('employees-table-body');
if (!tbody) return;
if (!this.employeesData || this.employeesData.length === 0) {
tbody.innerHTML = '
| Нет сотрудников |
';
return;
}
const roleLabels = { production: 'Производство', office: 'Офис', management: 'Руководство' };
const roleBadges = { production: 'badge-blue', office: 'badge-yellow', management: 'badge-green' };
tbody.innerHTML = this.employeesData.map(e => {
const roleBadge = `${roleLabels[e.role] || e.role}`;
const tgStatus = e.telegram_id
? `✔`
: `—`;
const reminderTime = `${String(e.reminder_hour || 17).padStart(2, '0')}:${String(e.reminder_minute || 30).padStart(2, '0')} UTC+${e.timezone_offset || 3}`;
const tasksIcon = e.tasks_required ? '✎' : '';
const statusMeta = this.getEmployeeStatusMeta(e);
const statusBadge = `${this.escHtml(statusMeta.label)}`;
const authMeta = this.getEmployeeAuthMeta(e);
const payrollLabel = this.getEmployeePayrollProfileLabel(e);
const isFired = statusMeta.key === 'fired';
const rowStyle = isFired ? 'opacity:0.5' : '';
return `
| ${this.escHtml(e.name)} |
${roleBadge} |
${e.daily_hours || 8}ч |
${tgStatus} |
${reminderTime} |
${tasksIcon} |
${authMeta.badge}
${authMeta.details}
|
${this.escHtml(payrollLabel)}
|
${statusBadge} |
|
`;
}).join('');
},
showAddEmployee() {
this.editingEmployeeId = null;
this.clearEmployeeForm();
document.getElementById('employee-form').style.display = '';
document.getElementById('emp-delete-btn').style.display = 'none';
// Hide salary section from non-admin
const paySection = document.getElementById('emp-pay-section');
if (paySection) paySection.style.display = App.isAdmin() ? '' : 'none';
this.onEmployeeStatusChange();
this.onPayrollProfileChange();
this.renderEmployeeAuthSummary({
id: null,
is_active: true,
fired_date: null,
payroll_profile: document.getElementById('emp-payroll-profile')?.value || 'hourly',
});
document.getElementById('emp-name').focus();
},
// Tax calculation: white salary is NET → gross = net / 0.87
// НДФЛ = gross × 13%, Social = gross × 30.2%
// Total cost = white_net + black + НДФЛ + Social = gross×(1+0.302) + black
NDFL_RATE: 0.13,
SOCIAL_RATE: 0.302,
calcEmployeeTaxes(whiteNet) {
if (!whiteNet || whiteNet <= 0) return { gross: 0, ndfl: 0, social: 0, totalTaxes: 0 };
const gross = Math.round(whiteNet / (1 - this.NDFL_RATE));
const ndfl = Math.round(gross * this.NDFL_RATE);
const social = Math.round(gross * this.SOCIAL_RATE);
return { gross, ndfl, social, totalTaxes: ndfl + social };
},
calcEmployeeTotalCost(whiteNet, black) {
const { totalTaxes } = this.calcEmployeeTaxes(whiteNet);
return (whiteNet || 0) + (black || 0) + totalTaxes;
},
getEmployeeEmploymentStatus(employee) {
if (!employee) return 'active';
if (employee.fired_date) return 'fired';
if (employee.is_active === false) return 'inactive';
return 'active';
},
getEmployeeStatusMeta(employee) {
const status = this.getEmployeeEmploymentStatus(employee);
if (status === 'fired') {
return {
key: 'fired',
label: employee?.fired_date ? `Уволен ${employee.fired_date}` : 'Уволен',
hint: 'Сотрудник уволен: история часов сохранится, но логин и новые назначения должны быть отключены.',
badgeClass: 'badge',
};
}
if (status === 'inactive') {
return {
key: 'inactive',
label: 'Пауза / неактивен',
hint: 'Сотрудник временно не работает: логин можно держать выключенным, история часов остается.',
badgeClass: 'badge',
};
}
return {
key: 'active',
label: 'Активен',
hint: 'Сотрудник работает сейчас и может сдавать часы и заходить в систему.',
badgeClass: 'badge badge-green',
};
},
getEmployeePayrollProfile(employee) {
const explicit = String(employee?.payroll_profile || '').trim();
if (explicit) return explicit;
const baseSalary = (parseFloat(employee?.pay_white_salary) || 0) + (parseFloat(employee?.pay_black_salary) || 0);
if (String(employee?.role || '') === 'management' && baseSalary > 0) return 'management_salary_with_production_allocation';
if (baseSalary > 0) return 'salary_monthly';
return 'hourly';
},
getEmployeePayrollProfileLabel(employee) {
const key = this.getEmployeePayrollProfile(employee);
return this.PAYROLL_PROFILE_LABELS[key] || key || '—';
},
getEmployeeAuthAccount(employeeId) {
return (this.authAccountsData || []).find(a => String(a.employee_id || '') === String(employeeId || '')) || null;
},
getEmployeeAuthMeta(employee) {
const account = this.getEmployeeAuthAccount(employee?.id);
if (!account) {
return {
state: 'missing',
badge: 'Нет логина',
details: 'Логин еще не выдан',
account: null,
};
}
const stateBadge = account.is_active === false
? 'Логин выключен'
: 'Логин активен';
const lastLogin = account.last_login_at
? new Date(account.last_login_at).toLocaleString('ru-RU')
: 'не заходил';
return {
state: account.is_active === false ? 'disabled' : 'active',
badge: stateBadge,
details: `${this.escHtml(account.username || '—')} · ${this.escHtml(lastLogin)}`,
account,
};
},
normalizePersonName(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/ё/g, 'е')
.replace(/[._-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, '')
.trim();
},
getPersonShortKey(name) {
const normalized = this.normalizePersonName(name);
return normalized.split(' ').filter(Boolean)[0] || '';
},
suggestEmployeeForName(name, options = {}) {
const normalized = this.normalizePersonName(name);
if (!normalized) return null;
const excludeIds = new Set((options.excludeIds || []).map(v => String(v)));
const exact = (this.employeesData || []).filter(e =>
!excludeIds.has(String(e.id)) &&
this.normalizePersonName(e.name) === normalized
);
if (exact.length === 1) {
return { employee: exact[0], confidence: 'exact' };
}
const shortKey = this.getPersonShortKey(name);
const shortMatches = shortKey
? (this.employeesData || []).filter(e =>
!excludeIds.has(String(e.id)) &&
this.getPersonShortKey(e.name) === shortKey
)
: [];
if (shortMatches.length === 1) {
return { employee: shortMatches[0], confidence: 'short' };
}
return null;
},
buildEmployeeAuthAudit() {
const employees = Array.isArray(this.employeesData) ? this.employeesData : [];
const accounts = Array.isArray(this.authAccountsData) ? this.authAccountsData : [];
const entries = Array.isArray(this.timeEntriesData) ? this.timeEntriesData : [];
const employeesById = new Map(employees.map(e => [String(e.id), e]));
const accountsByEmployeeId = new Map();
accounts.forEach(account => {
const key = String(account.employee_id || '');
if (!key) return;
const list = accountsByEmployeeId.get(key) || [];
list.push(account);
accountsByEmployeeId.set(key, list);
});
const issues = [];
const pushIssue = (issue) => {
issues.push({
severity: issue.severity || 'warn',
kind: issue.kind || 'generic',
title: issue.title || '',
detail: issue.detail || '',
accountId: issue.accountId ?? null,
employeeId: issue.employeeId ?? null,
entryId: issue.entryId ?? null,
suggestedEmployeeId: issue.suggestedEmployeeId ?? null,
canAutoRelink: !!issue.canAutoRelink,
});
};
accounts.forEach(account => {
const employeeId = account.employee_id != null ? String(account.employee_id) : '';
const linkedEmployee = employeeId ? employeesById.get(employeeId) : null;
const suggested = !linkedEmployee ? this.suggestEmployeeForName(account.employee_name || account.username || '') : null;
if (!linkedEmployee) {
pushIssue({
severity: suggested && suggested.confidence === 'exact' ? 'warn' : 'error',
kind: 'account_missing_employee',
title: `Логин "${account.username || '—'}" не связан с сотрудником`,
detail: suggested
? `Похоже на сотрудника "${suggested.employee.name}" (${suggested.confidence === 'exact' ? 'точное совпадение' : 'совпадение по короткому имени'}).`
: 'Нужна ручная проверка привязки логина к сотруднику.',
accountId: account.id,
suggestedEmployeeId: suggested?.employee?.id || null,
canAutoRelink: suggested?.confidence === 'exact',
});
return;
}
if (this.normalizePersonName(account.employee_name || '') !== this.normalizePersonName(linkedEmployee.name || '')) {
pushIssue({
severity: 'warn',
kind: 'account_name_mismatch',
title: `Имя в логине расходится с карточкой сотрудника`,
detail: `Логин хранит "${account.employee_name || '—'}", а сотрудник называется "${linkedEmployee.name || '—'}".`,
accountId: account.id,
employeeId: linkedEmployee.id,
});
}
if ((linkedEmployee.is_active === false || linkedEmployee.fired_date) && account.is_active !== false) {
pushIssue({
severity: 'warn',
kind: 'account_active_on_inactive_employee',
title: `Активный логин у неактивного сотрудника`,
detail: `Сотрудник "${linkedEmployee.name}" помечен как ${linkedEmployee.fired_date ? 'уволен' : 'неактивен'}, но логин еще включен.`,
accountId: account.id,
employeeId: linkedEmployee.id,
});
}
});
employees.forEach(employee => {
const linkedAccounts = accountsByEmployeeId.get(String(employee.id)) || [];
if (employee.is_active !== false && !employee.fired_date && linkedAccounts.length === 0) {
pushIssue({
severity: 'info',
kind: 'employee_without_login',
title: `У активного сотрудника "${employee.name}" нет логина`,
detail: 'Если сотруднику нужен доступ в систему, логин можно выдать прямо из карточки сотрудника.',
employeeId: employee.id,
});
}
if (linkedAccounts.length > 1) {
pushIssue({
severity: 'error',
kind: 'employee_multiple_accounts',
title: `У сотрудника "${employee.name}" несколько логинов`,
detail: 'Нужна ручная чистка: у одного сотрудника должно быть не больше одного активного логина.',
employeeId: employee.id,
});
}
});
entries.forEach(entry => {
const workerName = String(entry.worker_name || '').trim();
const entryEmployee = entry.employee_id != null ? employeesById.get(String(entry.employee_id)) : null;
if (entry.employee_id != null && !entryEmployee) {
pushIssue({
severity: 'error',
kind: 'time_entry_missing_employee',
title: `Часы ссылаются на отсутствующего сотрудника`,
detail: `Запись "${workerName || 'без имени'}" содержит employee_id ${entry.employee_id}, которого нет в справочнике сотрудников.`,
entryId: entry.id || null,
});
return;
}
if (entryEmployee && workerName && this.normalizePersonName(workerName) !== this.normalizePersonName(entryEmployee.name || '')) {
pushIssue({
severity: 'warn',
kind: 'time_entry_name_snapshot_mismatch',
title: `Имя в часах не совпадает с карточкой сотрудника`,
detail: `В часах сохранено "${workerName}", а текущая карточка сотрудника называется "${entryEmployee.name}".`,
employeeId: entryEmployee.id,
entryId: entry.id || null,
});
return;
}
if (entry.employee_id == null && workerName) {
const suggested = this.suggestEmployeeForName(workerName);
pushIssue({
severity: suggested && suggested.confidence === 'exact' ? 'warn' : 'error',
kind: 'time_entry_unlinked',
title: `Часы "${workerName}" не имеют employee_id`,
detail: suggested
? `Похоже на сотрудника "${suggested.employee.name}" (${suggested.confidence === 'exact' ? 'точное совпадение' : 'совпадение по короткому имени'}), но запись пока не связана канонически.`
: 'Нужна ручная проверка historical hours, чтобы не потерять привязку при миграции.',
entryId: entry.id || null,
suggestedEmployeeId: suggested?.employee?.id || null,
});
}
});
const severityWeight = { error: 3, warn: 2, info: 1 };
issues.sort((a, b) => (severityWeight[b.severity] || 0) - (severityWeight[a.severity] || 0));
return {
generatedAt: new Date().toISOString(),
employeesCount: employees.length,
accountsCount: accounts.length,
timeEntriesCount: entries.length,
summary: {
error: issues.filter(i => i.severity === 'error').length,
warn: issues.filter(i => i.severity === 'warn').length,
info: issues.filter(i => i.severity === 'info').length,
},
issues,
};
},
renderEmployeeAuthAudit() {
const container = document.getElementById('auth-linkage-audit');
if (!container) return;
const audit = this.employeeAuthAudit || this.buildEmployeeAuthAudit();
this.employeeAuthAudit = audit;
const issues = audit.issues || [];
if (!issues.length) {
container.style.display = '';
container.innerHTML = `
Связка сотрудник ↔ логин ↔ часы чистая
Проблемных конфликтов не найдено. Исторические часы и логины выглядят согласованными.
`;
return;
}
const badge = (count, label, bg, color) => `${count} ${label}`;
const topIssues = issues.slice(0, 12).map(issue => {
const colors = {
error: { border: 'rgba(239,68,68,.25)', bg: '#fef2f2', title: '#991b1b' },
warn: { border: 'rgba(245,158,11,.25)', bg: '#fffbeb', title: '#92400e' },
info: { border: 'rgba(59,130,246,.25)', bg: '#eff6ff', title: '#1d4ed8' },
};
const palette = colors[issue.severity] || colors.warn;
const relinkBtn = issue.canAutoRelink && issue.accountId && issue.suggestedEmployeeId
? ``
: '';
return `
${this.escHtml(issue.title)}
${this.escHtml(issue.detail)}
${relinkBtn}
`;
}).join('');
container.style.display = '';
container.innerHTML = `
Audit связки сотрудников, логинов и часов
Ниже только безопасная диагностика: спорные случаи не склеиваются автоматически. Для точных совпадений доступна аккуратная перепривязка логина.
${badge(audit.summary.error, 'ошибок', '#fef2f2', '#991b1b')}
${badge(audit.summary.warn, 'предупр.', '#fffbeb', '#92400e')}
${badge(audit.summary.info, 'подсказок', '#eff6ff', '#1d4ed8')}
${topIssues}
${issues.length > 12 ? `
Показаны первые 12 проблем из ${issues.length}. Полный список доступен в export audit.
` : ''}
`;
},
renderEmployeeAuthSummary(employee) {
const box = document.getElementById('emp-auth-summary');
const openBtn = document.getElementById('emp-auth-open-btn');
const createBtn = document.getElementById('emp-auth-create-btn');
if (!box || !openBtn || !createBtn) return;
const authMeta = this.getEmployeeAuthMeta(employee);
const payrollLabel = this.getEmployeePayrollProfileLabel(employee);
const statusMeta = this.getEmployeeStatusMeta(employee);
box.innerHTML = `
${authMeta.badge}
${this.escHtml(statusMeta.label)}
${authMeta.details}
Схема оплаты: ${this.escHtml(payrollLabel)}
`;
openBtn.style.display = authMeta.account ? '' : 'none';
createBtn.style.display = authMeta.account ? 'none' : '';
},
onEmployeeStatusChange() {
const status = document.getElementById('emp-status')?.value || 'active';
const firedWrap = document.getElementById('emp-fired-wrap');
const firedEl = document.getElementById('emp-fired-date');
const noteEl = document.getElementById('emp-status-note');
if (firedWrap) firedWrap.style.display = status === 'fired' ? '' : 'none';
if (status !== 'fired' && firedEl) firedEl.value = '';
if (status === 'fired' && firedEl && !firedEl.value) {
firedEl.value = new Date().toISOString().split('T')[0];
}
if (noteEl) {
const meta = this.getEmployeeStatusMeta({
is_active: status === 'active',
fired_date: status === 'fired' ? (firedEl?.value || null) : null,
});
noteEl.textContent = meta.hint;
}
},
onPayrollProfileChange() {
const profile = document.getElementById('emp-payroll-profile')?.value || 'hourly';
const halfWrap = document.getElementById('emp-pay-half-hours-wrap');
const baseHoursEl = document.getElementById('emp-pay-base-hours');
const hintEl = document.getElementById('emp-pay-profile-hint');
if (halfWrap) halfWrap.style.display = profile === 'salary_semimonth_threshold' ? '' : 'none';
if (baseHoursEl) {
if (profile === 'hourly') baseHoursEl.placeholder = 'Не используется для почасовой схемы';
else if (profile === 'salary_semimonth_threshold') baseHoursEl.placeholder = 'Например 120';
else baseHoursEl.placeholder = 'Например 176';
}
if (hintEl) {
const hints = {
hourly: 'Почасовая схема: окладные часы не используются, оплата считается по ставкам за часы.',
salary_monthly: 'Оклад за месяц: включенные часы считаются одним месячным bucket, как сейчас.',
salary_semimonth_threshold: 'Оклад 60 + 60: первая и вторая половина месяца считаются отдельными bucket для доп. часов.',
management_salary_with_production_allocation: 'Оклад идет в косвенные, а фактические производственные часы постепенно вытаскивают часть стоимости в производство.',
};
hintEl.textContent = hints[profile] || '';
}
},
recalcEmployeeCost() {
const white = parseFloat(document.getElementById('emp-pay-white')?.value) || 0;
const black = parseFloat(document.getElementById('emp-pay-black')?.value) || 0;
const { ndfl, social, totalTaxes } = this.calcEmployeeTaxes(white);
const total = white + black + totalTaxes;
const fmt = n => new Intl.NumberFormat('ru-RU').format(n) + ' ₽';
const ndflEl = document.getElementById('emp-tax-ndfl');
const socialEl = document.getElementById('emp-tax-social');
const totalEl = document.getElementById('emp-total-cost');
if (ndflEl) ndflEl.value = white > 0 ? fmt(ndfl) : '—';
if (socialEl) socialEl.value = white > 0 ? fmt(social) : '—';
if (totalEl) totalEl.value = total > 0 ? fmt(total) : '—';
},
editEmployee(id) {
id = String(id);
const e = this.employeesData.find(x => String(x.id) === id);
if (!e) return;
this.editingEmployeeId = id;
document.getElementById('emp-edit-id').value = id;
document.getElementById('emp-name').value = e.name || '';
document.getElementById('emp-role').value = e.role || 'production';
document.getElementById('emp-daily-hours').value = e.daily_hours || 8;
document.getElementById('emp-tg-username').value = e.telegram_username || '';
document.getElementById('emp-reminder-hour').value = e.reminder_hour ?? 17;
document.getElementById('emp-reminder-min').value = e.reminder_minute ?? 30;
document.getElementById('emp-tz-offset').value = e.timezone_offset ?? 3;
document.getElementById('emp-tasks-required').checked = !!e.tasks_required;
// Migration: if no white/black split exists but pay_base_salary_month does, treat as all black
let white = parseFloat(e.pay_white_salary) || 0;
let black = parseFloat(e.pay_black_salary) || 0;
if (white === 0 && black === 0 && (parseFloat(e.pay_base_salary_month) || 0) > 0) {
black = parseFloat(e.pay_base_salary_month);
}
document.getElementById('emp-pay-white').value = white;
document.getElementById('emp-pay-black').value = black;
document.getElementById('emp-pay-base-hours').value = parseFloat(e.pay_base_hours_month) || 176;
document.getElementById('emp-payroll-profile').value = this.getEmployeePayrollProfile(e);
document.getElementById('emp-pay-half-hours').value = parseFloat(e.pay_base_hours_semimonth) || '';
document.getElementById('emp-pay-overtime-rate').value = parseFloat(e.pay_overtime_hour_rate) || 0;
document.getElementById('emp-pay-weekend-rate').value = parseFloat(e.pay_weekend_hour_rate) || 0;
document.getElementById('emp-pay-holiday-rate').value = parseFloat(e.pay_holiday_hour_rate) || 0;
this.recalcEmployeeCost();
// Fired date
const firedEl = document.getElementById('emp-fired-date');
if (firedEl) firedEl.value = e.fired_date || '';
const statusEl = document.getElementById('emp-status');
if (statusEl) statusEl.value = this.getEmployeeEmploymentStatus(e);
this.onEmployeeStatusChange();
this.onPayrollProfileChange();
document.getElementById('employee-form').style.display = '';
document.getElementById('emp-delete-btn').style.display = '';
// Hide salary section from non-admin
const paySection = document.getElementById('emp-pay-section');
if (paySection) paySection.style.display = App.isAdmin() ? '' : 'none';
this.renderEmployeeAuthSummary(e);
},
clearEmployeeForm() {
document.getElementById('emp-edit-id').value = '';
document.getElementById('emp-name').value = '';
document.getElementById('emp-role').value = 'production';
document.getElementById('emp-daily-hours').value = 8;
document.getElementById('emp-tg-username').value = '';
document.getElementById('emp-reminder-hour').value = 17;
document.getElementById('emp-reminder-min').value = 30;
document.getElementById('emp-tz-offset').value = 3;
document.getElementById('emp-tasks-required').checked = false;
document.getElementById('emp-status').value = 'active';
document.getElementById('emp-fired-date').value = '';
document.getElementById('emp-pay-white').value = '';
document.getElementById('emp-pay-black').value = '';
document.getElementById('emp-pay-base-hours').value = 176;
document.getElementById('emp-payroll-profile').value = 'hourly';
document.getElementById('emp-pay-half-hours').value = '';
document.getElementById('emp-pay-overtime-rate').value = '';
document.getElementById('emp-pay-weekend-rate').value = '';
document.getElementById('emp-pay-holiday-rate').value = '';
document.getElementById('emp-tax-ndfl').value = '';
document.getElementById('emp-tax-social').value = '';
document.getElementById('emp-total-cost').value = '';
const noteEl = document.getElementById('emp-status-note');
if (noteEl) noteEl.textContent = '';
},
cancelEmployee() {
document.getElementById('employee-form').style.display = 'none';
this.editingEmployeeId = null;
},
openEmployeeAuthAccount(employeeId = null) {
const targetEmployeeId = employeeId || this.editingEmployeeId;
if (!targetEmployeeId) {
App.toast('Сначала сохраните сотрудника');
return;
}
const account = this.getEmployeeAuthAccount(targetEmployeeId);
if (!account) {
this.openNewAuthAccountForEmployee(targetEmployeeId);
return;
}
this.switchTab('logins');
this.editAuthAccount(account.id);
},
openNewAuthAccountForEmployee(employeeId = null) {
const targetEmployeeId = employeeId || this.editingEmployeeId;
if (!targetEmployeeId) {
App.toast('Сначала сохраните сотрудника');
return;
}
this.switchTab('logins');
this.showAddAuthAccount();
const select = document.getElementById('auth-account-employee');
if (select) {
select.value = String(targetEmployeeId);
this._renderAuthPageCheckboxes(targetEmployeeId);
}
this.prefillSuggestedAuthCredentials(targetEmployeeId);
},
async syncAuthAccountWithEmployee(employee) {
if (!employee || !employee.id) return;
const account = this.getEmployeeAuthAccount(employee.id);
if (!account) return;
let changed = false;
if ((account.employee_name || '') !== (employee.name || '')) {
account.employee_name = employee.name || '';
changed = true;
}
if ((account.role || '') !== (employee.role || 'employee')) {
account.role = employee.role || 'employee';
changed = true;
}
if (employee.is_active === false && account.is_active !== false) {
account.is_active = false;
changed = true;
}
if (!changed) return;
account.updated_at = new Date().toISOString();
await saveAuthAccounts(this.authAccountsData);
await App.refreshAuthUsers();
},
// Page access checkboxes
PAGE_LABELS: {
calculator: 'Калькулятор', orders: 'Заказы',
factual: 'План-Факт', analytics: 'Аналитика',
molds: 'Молды', colors: 'Цвета', timetrack: 'Учёт времени',
tasks: 'Задачи', bugs: 'Баги', projects: 'Проекты', wiki: 'База знаний', gantt: 'Производственный календарь', import: 'Импорт',
warehouse: 'Склад', marketplaces: 'Маркетплейсы', china: 'Китай',
monitoring: 'Мониторинг',
settings: 'Настройки',
},
async saveEmployee() {
const name = document.getElementById('emp-name').value.trim();
if (!name) { App.toast('Введите имя сотрудника'); return; }
const isNewEmployee = !this.editingEmployeeId;
const employmentStatus = document.getElementById('emp-status')?.value || 'active';
const firedDateInput = document.getElementById('emp-fired-date')?.value || '';
const firedDate = employmentStatus === 'fired'
? (firedDateInput || new Date().toISOString().split('T')[0])
: null;
const isActive = employmentStatus === 'active';
const employee = {
id: this.editingEmployeeId || undefined,
name,
role: document.getElementById('emp-role').value,
daily_hours: parseFloat(document.getElementById('emp-daily-hours').value) || 8,
telegram_username: document.getElementById('emp-tg-username').value.trim(),
reminder_hour: parseInt(document.getElementById('emp-reminder-hour').value) || 17,
reminder_minute: parseInt(document.getElementById('emp-reminder-min').value) || 30,
timezone_offset: parseInt(document.getElementById('emp-tz-offset').value) ?? 3,
is_active: isActive,
fired_date: firedDate,
tasks_required: document.getElementById('emp-tasks-required').checked,
pay_white_salary: parseFloat(document.getElementById('emp-pay-white').value) || 0,
pay_black_salary: parseFloat(document.getElementById('emp-pay-black').value) || 0,
pay_base_salary_month: (parseFloat(document.getElementById('emp-pay-white').value) || 0) + (parseFloat(document.getElementById('emp-pay-black').value) || 0),
pay_base_hours_month: parseFloat(document.getElementById('emp-pay-base-hours').value) || 176,
payroll_profile: document.getElementById('emp-payroll-profile').value || 'hourly',
pay_base_hours_semimonth: parseFloat(document.getElementById('emp-pay-half-hours').value) || 0,
pay_overtime_hour_rate: parseFloat(document.getElementById('emp-pay-overtime-rate').value) || 0,
pay_weekend_hour_rate: parseFloat(document.getElementById('emp-pay-weekend-rate').value) || 0,
pay_holiday_hour_rate: parseFloat(document.getElementById('emp-pay-holiday-rate').value) || 0,
};
// Preserve telegram_id if editing existing
if (this.editingEmployeeId) {
const existing = this.employeesData.find(e => String(e.id) === String(this.editingEmployeeId));
if (existing) {
employee.telegram_id = existing.telegram_id || null;
}
}
const savedId = await saveEmployee(employee);
if (!savedId) {
App.toast('Не удалось сохранить сотрудника', 'error');
return;
}
employee.id = savedId;
await this.syncAuthAccountWithEmployee(employee);
if (isNewEmployee) {
await this.ensureLoginForNewEmployee({ ...employee, id: savedId });
}
App.toast('Сотрудник сохранён');
this.cancelEmployee();
await this.loadEmployeesTab();
await App.refreshEmployees();
await App.refreshAuthUsers();
},
async deleteEmployee() {
if (!this.editingEmployeeId) return;
const e = this.employeesData.find(x => String(x.id) === String(this.editingEmployeeId));
if (!confirm(`Удалить сотрудника "${e?.name || ''}"?`)) return;
await deleteEmployee(this.editingEmployeeId);
App.toast('Сотрудник удалён');
this.cancelEmployee();
await this.loadEmployeesTab();
await App.refreshEmployees();
},
// ==========================================
// AUTH LOGINS — system accounts per employee
// ==========================================
async loadLoginsTab() {
this.employeesData = await loadEmployees();
this.authAccountsData = await loadAuthAccounts();
this.timeEntriesData = typeof loadTimeEntries === 'function' ? ((await loadTimeEntries()) || []) : [];
await this.ensureAutoLoginsForEmployees();
this.authActivityData = await loadAuthActivity();
this.employeeAuthAudit = this.buildEmployeeAuthAudit();
this.renderEmployeeAuthAudit();
this.renderAuthAccountsTable();
this.renderAuthActivityTable();
this.renderIssuedAuthCredentials();
},
normalizeNameForLogin(name) {
return String(name || '')
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-zа-яё0-9_-]+/gi, '')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
},
getAutoLoginBase(name) {
const normalized = this.normalizeNameForLogin(name);
const stem = normalized || 'user';
return `${stem}_ro`;
},
getUniqueAutoUsername(baseUsername, excludeAccountId = null) {
const used = new Set((this.authAccountsData || [])
.filter(a => String(a.id) !== String(excludeAccountId || ''))
.map(a => String(a.username || '').toLowerCase())
.filter(Boolean));
if (!used.has(baseUsername.toLowerCase())) return baseUsername.toLowerCase();
let i = 1;
while (used.has(`${baseUsername.toLowerCase()}_${i}`)) i++;
return `${baseUsername.toLowerCase()}_${i}`;
},
getExistingAuthAccountByEmployeeId(employeeId, excludeAccountId = null) {
return (this.authAccountsData || []).find(a =>
String(a.employee_id || '') === String(employeeId || '') &&
String(a.id) !== String(excludeAccountId || '')
) || null;
},
getSuggestedUsernameForEmployee(employee, excludeAccountId = null) {
if (!employee) return '';
return this.getUniqueAutoUsername(this.getAutoLoginBase(employee.name), excludeAccountId);
},
generateStrongPassword(length = 12) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
const bytes = new Uint32Array(length);
if (window.crypto && window.crypto.getRandomValues) {
window.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 4294967295);
}
let out = '';
for (let i = 0; i < length; i++) {
out += alphabet[bytes[i] % alphabet.length];
}
return out;
},
shouldSkipAutoLoginForEmployee(employeeName) {
const skip = new Set(['алина', 'аня', 'полина']);
return skip.has(String(employeeName || '').trim().toLowerCase());
},
async ensureLoginForNewEmployee(employee) {
if (!employee || !employee.id) return;
if (this.shouldSkipAutoLoginForEmployee(employee.name)) return;
await appendAuthActivity({
type: 'account_auto_create_skipped',
actor: App.getCurrentEmployeeName(),
target_user: employee.name || '',
});
App.toast('Автосоздание логинов отключено: создайте логин вручную во вкладке "Логины"');
},
async ensureAutoLoginsForEmployees() {
return;
},
async loadSessionsTab() {
this.authSessionsData = await loadAuthSessions();
this.renderSessionsStats();
this.renderSessionsSummary();
this.renderSessionsList();
},
showAddAuthAccount() {
this.editingAuthAccountId = null;
this.clearAuthAccountForm();
this.populateAuthEmployeeSelect(true);
document.getElementById('auth-account-form').style.display = '';
document.getElementById('auth-account-delete-btn').style.display = 'none';
const select = document.getElementById('auth-account-employee');
if (select && select.value) {
this._renderAuthPageCheckboxes(select.value);
this.prefillSuggestedAuthCredentials(select.value);
}
},
populateAuthEmployeeSelect(preferEmployeesWithoutAccount = false) {
const select = document.getElementById('auth-account-employee');
if (!select) return;
const active = (this.employeesData || []).filter(e => e.is_active !== false);
const employeesWithoutAccount = active.filter(e => !this.getExistingAuthAccountByEmployeeId(e.id, this.editingAuthAccountId));
const source = preferEmployeesWithoutAccount && employeesWithoutAccount.length ? employeesWithoutAccount : active;
const placeholder = source.length ? '-- Выберите сотрудника --' : '-- Нет сотрудников без логина --';
select.onchange = () => {
const empId = select.value;
if (!empId) return;
this._renderAuthPageCheckboxes(empId);
if (!this.editingAuthAccountId) this.prefillSuggestedAuthCredentials(empId);
};
let html = ``;
html += source.map(e => ``).join('');
select.innerHTML = html;
if (!this.editingAuthAccountId && source.length === 1) {
select.value = String(source[0].id);
}
},
clearAuthAccountForm() {
const idEl = document.getElementById('auth-account-id');
if (idEl) idEl.value = '';
const empEl = document.getElementById('auth-account-employee');
if (empEl) empEl.value = '';
const userEl = document.getElementById('auth-account-username');
if (userEl) userEl.value = '';
const passEl = document.getElementById('auth-account-password');
if (passEl) passEl.value = '';
const activeEl = document.getElementById('auth-account-active');
if (activeEl) activeEl.value = '1';
},
prefillSuggestedAuthCredentials(employeeId) {
const employee = (this.employeesData || []).find(e => String(e.id) === String(employeeId || ''));
if (!employee) return;
const userEl = document.getElementById('auth-account-username');
const passEl = document.getElementById('auth-account-password');
if (userEl) userEl.value = this.getSuggestedUsernameForEmployee(employee, this.editingAuthAccountId);
if (passEl) passEl.value = this.generateStrongPassword(12);
},
generateSuggestedAuthUsername() {
const employeeId = document.getElementById('auth-account-employee')?.value || '';
if (!employeeId) {
App.toast('Сначала выберите сотрудника');
return;
}
const employee = (this.employeesData || []).find(e => String(e.id) === String(employeeId));
if (!employee) {
App.toast('Сотрудник не найден');
return;
}
const userEl = document.getElementById('auth-account-username');
if (userEl) userEl.value = this.getSuggestedUsernameForEmployee(employee, this.editingAuthAccountId);
},
generateSuggestedAuthPassword() {
const passEl = document.getElementById('auth-account-password');
if (passEl) passEl.value = this.generateStrongPassword(12);
},
cancelAuthAccount() {
document.getElementById('auth-account-form').style.display = 'none';
this.editingAuthAccountId = null;
},
editAuthAccount(id) {
const a = (this.authAccountsData || []).find(x => String(x.id) === String(id));
if (!a) return;
this.editingAuthAccountId = a.id;
this.populateAuthEmployeeSelect();
document.getElementById('auth-account-id').value = String(a.id);
document.getElementById('auth-account-employee').value = String(a.employee_id || '');
document.getElementById('auth-account-username').value = a.username || '';
document.getElementById('auth-account-password').value = '';
document.getElementById('auth-account-active').value = a.is_active === false ? '0' : '1';
document.getElementById('auth-account-form').style.display = '';
document.getElementById('auth-account-delete-btn').style.display = '';
// Render page access checkboxes for this employee
if (a.employee_id) this._renderAuthPageCheckboxes(a.employee_id);
},
// Page checkboxes in auth account form
_renderAuthPageCheckboxes(empId) {
const container = document.getElementById('auth-pages-checkboxes');
if (!container) return;
const allowed = App.getEmployeePages(empId) || [...App.DEFAULT_PAGES];
container.innerHTML = App.ALL_PAGES.filter(page => page !== 'monitoring').map(page => {
const checked = allowed.includes(page) ? 'checked' : '';
const label = this.PAGE_LABELS[page] || page;
return ``;
}).join('');
},
_saveAuthPageCheckboxes(empId) {
const cbs = document.querySelectorAll('#auth-pages-checkboxes .auth-page-cb');
if (!cbs.length) return;
const pages = [];
cbs.forEach(cb => { if (cb.checked) pages.push(cb.dataset.page); });
App.setEmployeePages(empId, pages);
},
authPagesSelectAll() {
document.querySelectorAll('#auth-pages-checkboxes .auth-page-cb').forEach(cb => cb.checked = true);
},
authPagesSelectNone() {
document.querySelectorAll('#auth-pages-checkboxes .auth-page-cb').forEach(cb => cb.checked = false);
},
async saveAuthAccount() {
const employeeId = parseInt(document.getElementById('auth-account-employee').value, 10);
const rawUsername = (document.getElementById('auth-account-username').value || '').trim().toLowerCase();
const typedPassword = String(document.getElementById('auth-account-password').value || '').trim();
const isActive = document.getElementById('auth-account-active').value === '1';
if (!employeeId) { App.toast('Выберите сотрудника'); return; }
const employee = (this.employeesData || []).find(e => Number(e.id) === employeeId);
if (!employee) { App.toast('Сотрудник не найден'); return; }
if (!this.editingAuthAccountId) {
const existingForEmployee = this.getExistingAuthAccountByEmployeeId(employeeId);
if (existingForEmployee) {
this.editAuthAccount(existingForEmployee.id);
App.toast('У этого сотрудника уже есть логин — открыла его для редактирования');
return;
}
}
let resolvedUsername = rawUsername || this.getSuggestedUsernameForEmployee(employee, this.editingAuthAccountId);
if (!resolvedUsername) { App.toast('Не удалось подобрать логин'); return; }
const duplicate = (this.authAccountsData || []).find(a =>
(a.username || '').toLowerCase() === resolvedUsername && String(a.id) !== String(this.editingAuthAccountId)
);
if (duplicate) {
const fallbackUsername = this.getUniqueAutoUsername(resolvedUsername, this.editingAuthAccountId);
if (!fallbackUsername) {
App.toast('Логин уже занят');
return;
}
if (fallbackUsername !== resolvedUsername) {
resolvedUsername = fallbackUsername;
const userEl = document.getElementById('auth-account-username');
if (userEl) userEl.value = resolvedUsername;
App.toast(`Логин был занят, подставила свободный: ${resolvedUsername}`);
}
}
let account = null;
let prevUsername = '';
if (this.editingAuthAccountId) {
account = this.authAccountsData.find(a => String(a.id) === String(this.editingAuthAccountId));
if (!account) return;
prevUsername = String(account.username || '').toLowerCase();
} else {
account = {
id: Date.now(),
created_at: new Date().toISOString(),
last_login_at: null,
};
this.authAccountsData.push(account);
}
account.employee_id = employeeId;
account.employee_name = employee.name || '';
account.role = employee.role || 'employee';
account.username = resolvedUsername;
account.is_active = isActive;
account.updated_at = new Date().toISOString();
let issuedPassword = typedPassword;
if (!issuedPassword && (!this.editingAuthAccountId || !account.password_hash || (prevUsername && prevUsername !== resolvedUsername))) {
issuedPassword = this.generateStrongPassword(12);
const passEl = document.getElementById('auth-account-password');
if (passEl) passEl.value = issuedPassword;
}
if (issuedPassword) {
account.password_hash = App.hashUserPassword(resolvedUsername, issuedPassword);
account.password_hash_version = App.AUTH_PASSWORD_HASH_VERSION || 2;
account.password_rotated_at = new Date().toISOString();
delete account.password_plain;
} else if (!account.password_hash) {
App.toast('Не удалось сгенерировать пароль');
return;
}
// Save page access: both in localStorage AND in auth account object (for Supabase sync)
const cbs = document.querySelectorAll('#auth-pages-checkboxes .auth-page-cb');
if (cbs.length) {
const pages = [];
cbs.forEach(cb => { if (cb.checked) pages.push(cb.dataset.page); });
account.pages = pages;
App.setEmployeePages(employeeId, pages);
}
await saveAuthAccounts(this.authAccountsData);
await appendAuthActivity({
type: this.editingAuthAccountId ? 'account_update' : 'account_create',
actor: App.getCurrentEmployeeName(),
target_user: account.employee_name || account.username,
});
if (issuedPassword) {
this.lastIssuedAuthCredentials = {
accountId: account.id,
employeeName: account.employee_name || account.username || '',
username: resolvedUsername,
password: issuedPassword,
mode: this.editingAuthAccountId ? 'update' : 'create',
};
this.renderIssuedAuthCredentials();
}
App.toast('Логин сохранён');
this.cancelAuthAccount();
await this.loadLoginsTab();
await App.refreshAuthUsers();
},
describeAuthAccountSecurity(account) {
const currentVersion = Number(App.AUTH_PASSWORD_HASH_VERSION) || 2;
const accountVersion = App.getAccountPasswordHashVersion
? App.getAccountPasswordHashVersion(account)
: (parseInt(account?.password_hash_version, 10) || 1);
const rotatedAt = account?.password_rotated_at
? new Date(account.password_rotated_at).toLocaleDateString('ru-RU')
: '';
if (accountVersion < currentVersion) {
return {
title: 'выдать новый',
label: `Legacy hash v${accountVersion}`,
color: 'var(--yellow)',
};
}
return {
title: 'скрыт',
label: rotatedAt ? `Hash v${accountVersion} · обновлён ${rotatedAt}` : `Hash v${accountVersion}`,
color: 'var(--green)',
};
},
async deleteAuthAccount() {
if (!this.editingAuthAccountId) return;
const a = this.authAccountsData.find(x => String(x.id) === String(this.editingAuthAccountId));
if (!a) return;
if (!confirm(`Удалить логин "${a.username}"?`)) return;
this.authAccountsData = this.authAccountsData.filter(x => String(x.id) !== String(this.editingAuthAccountId));
await saveAuthAccounts(this.authAccountsData);
await appendAuthActivity({
type: 'account_delete',
actor: App.getCurrentEmployeeName(),
target_user: a.employee_name || a.username,
});
App.toast('Логин удалён');
this.cancelAuthAccount();
await this.loadLoginsTab();
await App.refreshAuthUsers();
},
async relinkAuthAccountToEmployee(accountId, employeeId) {
const account = (this.authAccountsData || []).find(a => String(a.id) === String(accountId));
const employee = (this.employeesData || []).find(e => String(e.id) === String(employeeId));
if (!account || !employee) {
App.toast('Не удалось найти логин или сотрудника');
return;
}
const existingForEmployee = this.getExistingAuthAccountByEmployeeId(employee.id, account.id);
if (existingForEmployee) {
App.toast('У этого сотрудника уже есть другой логин. Нужна ручная проверка.');
return;
}
if (!confirm(`Привязать логин "${account.username || '—'}" к сотруднику "${employee.name}"?`)) return;
account.employee_id = employee.id;
account.employee_name = employee.name || '';
account.role = employee.role || account.role || 'employee';
if (employee.is_active === false || employee.fired_date) {
account.is_active = false;
}
account.updated_at = new Date().toISOString();
await saveAuthAccounts(this.authAccountsData);
await appendAuthActivity({
type: 'account_relink',
actor: App.getCurrentEmployeeName(),
target_user: account.employee_name || account.username,
});
App.toast('Логин перепривязан к сотруднику');
await this.loadLoginsTab();
await App.refreshAuthUsers();
},
renderAuthAccountsTable() {
const tbody = document.getElementById('auth-accounts-table-body');
if (!tbody) return;
if (!this.authAccountsData || this.authAccountsData.length === 0) {
tbody.innerHTML = '| Нет логинов |
';
return;
}
const issuesByAccountId = new Map();
(this.employeeAuthAudit?.issues || []).forEach(issue => {
if (issue.accountId == null) return;
const key = String(issue.accountId);
const list = issuesByAccountId.get(key) || [];
list.push(issue);
issuesByAccountId.set(key, list);
});
const rows = [...this.authAccountsData]
.sort((a, b) => String(a.employee_name || '').localeCompare(String(b.employee_name || ''), 'ru'))
.map(a => {
const status = a.is_active === false
? 'Отключен'
: 'Активен';
const employee = (this.employeesData || []).find(e => String(e.id || '') === String(a.employee_id || ''));
const employeeStatus = employee
? this.getEmployeeStatusMeta(employee)
: { label: 'Сотрудник не найден', badgeClass: 'badge', hint: 'Привязка сотрудника потеряна' };
const security = this.describeAuthAccountSecurity(a);
const last = a.last_login_at
? new Date(a.last_login_at).toLocaleString('ru-RU')
: '—';
const rowIssues = issuesByAccountId.get(String(a.id)) || [];
const issueNote = rowIssues.length
? `${this.escHtml(rowIssues[0].detail)}
`
: '';
return `
| ${this.escHtml(a.employee_name || '—')}${issueNote} |
${this.escHtml(a.username || '')} |
${this.escHtml(employeeStatus.label)} |
${this.escHtml(security.title)}${this.escHtml(security.label)} · нажмите «Сбросить», чтобы выдать новый пароль |
${status} |
${this.escHtml(last)} |
|
`;
});
tbody.innerHTML = rows.join('');
},
async resetAuthPassword(id) {
const account = (this.authAccountsData || []).find(a => String(a.id) === String(id));
if (!account) return;
if (!confirm(`Сгенерировать новый пароль для "${account.employee_name || account.username}"?`)) return;
const pass = this.generateStrongPassword(12);
if (!pass) {
App.toast('Не удалось сгенерировать пароль');
return;
}
account.password_hash = App.hashUserPassword(account.username, pass);
account.password_hash_version = App.AUTH_PASSWORD_HASH_VERSION || 2;
account.password_rotated_at = new Date().toISOString();
delete account.password_plain;
account.updated_at = new Date().toISOString();
await saveAuthAccounts(this.authAccountsData);
await appendAuthActivity({
type: 'password_reset',
actor: App.getCurrentEmployeeName(),
target_user: account.employee_name || account.username,
});
this.lastIssuedAuthCredentials = {
accountId: account.id,
employeeName: account.employee_name || account.username || '',
username: account.username || '',
password: pass,
mode: 'reset',
};
this.renderIssuedAuthCredentials();
this.renderAuthAccountsTable();
App.toast('Новый пароль сгенерирован и показан выше — скопируйте его для сотрудника.');
},
renderIssuedAuthCredentials() {
const container = document.getElementById('auth-issued-credentials');
if (!container) return;
const creds = this.lastIssuedAuthCredentials;
if (!creds || !creds.username || !creds.password) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
const modeLabel = creds.mode === 'reset'
? 'Новый пароль готов'
: creds.mode === 'update'
? 'Логин обновлён'
: 'Новый логин создан';
container.style.display = '';
container.innerHTML = `
${this.escHtml(modeLabel)}
Сотрудник: ${this.escHtml(creds.employeeName || '—')}
Система не хранит пароль открытым, поэтому передайте эти данные сейчас.
Логин
${this.escHtml(creds.username)}
Пароль
${this.escHtml(creds.password)}
`;
},
dismissIssuedAuthCredentials() {
this.lastIssuedAuthCredentials = null;
this.renderIssuedAuthCredentials();
},
async copyIssuedAuthCredentials(kind) {
const creds = this.lastIssuedAuthCredentials;
if (!creds) return;
const value = kind === 'password' ? creds.password : creds.username;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
App.toast(kind === 'password' ? 'Пароль скопирован' : 'Логин скопирован');
} catch (e) {
App.toast('Не удалось скопировать');
}
},
async downloadAuthBackup() {
const backup = {
_meta: {
app: 'RecycleObject',
type: 'auth-backup',
version: typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown',
date: new Date().toISOString(),
source: 'sanitized-loaders',
note: 'password_plain is intentionally excluded from backup exports',
},
auth_accounts: (await loadAuthAccounts()).map(account => ({
...account,
pages: App.normalizePageList(account.pages),
})),
auth_activity: await loadAuthActivity(),
auth_sessions: await loadAuthSessions(),
employee_pages: JSON.parse(localStorage.getItem('ro_employee_pages') || '{}'),
};
const json = JSON.stringify(backup, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
a.href = url;
a.download = `RO_auth_backup_${dateStr}.json`;
a.click();
URL.revokeObjectURL(url);
const infoEl = document.getElementById('auth-backup-info');
const accountCount = Array.isArray(backup.auth_accounts) ? backup.auth_accounts.length : 0;
const sizeKb = Math.round(json.length / 1024);
if (infoEl) {
infoEl.innerHTML = `Auth backup скачан: ${sizeKb} КБ, ${accountCount} аккаунтов`;
}
App.toast(`Auth backup скачан (${accountCount} аккаунтов)`);
return backup;
},
async downloadEmployeeAuthAudit() {
const audit = this.employeeAuthAudit || this.buildEmployeeAuthAudit();
const payload = {
_meta: {
app: 'RecycleObject',
type: 'employee-auth-audit',
version: typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown',
date: new Date().toISOString(),
},
audit,
};
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
a.href = url;
a.download = `RO_employee_auth_audit_${dateStr}.json`;
a.click();
URL.revokeObjectURL(url);
App.toast(`Employee/auth audit скачан (${audit.issues.length} проблем)`);
return payload;
},
renderAuthActivityTable() {
const tbody = document.getElementById('auth-activity-table-body');
if (!tbody) return;
const list = (this.authActivityData || []).slice(0, 100);
if (list.length === 0) {
tbody.innerHTML = '| Нет событий |
';
return;
}
const rows = list.map(e => {
const at = e.at ? new Date(e.at).toLocaleString('ru-RU') : '—';
const actor = this.escHtml(e.actor || '—');
const action = this.escHtml(e.type || '');
const page = this.escHtml(e.to_page || e.page || '');
return `
| ${at} |
${actor} |
${action} |
${page || '—'} |
`;
});
tbody.innerHTML = rows.join('');
},
renderSessionsStats() {
const sessions = this.normalizeSessions(this.authSessionsData || []);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const weekStart = now.getTime() - (7 * 24 * 3600 * 1000);
const todaySec = sessions
.filter(s => new Date(s.started_at).getTime() >= todayStart)
.reduce((sum, s) => sum + (s.duration_sec || 0), 0);
const weekSec = sessions
.filter(s => new Date(s.started_at).getTime() >= weekStart)
.reduce((sum, s) => sum + (s.duration_sec || 0), 0);
const activeNow = sessions.filter(s => s.effective_status === 'active').length;
const users = new Set(sessions.map(s => s.actor || '—').filter(Boolean)).size;
const todayEl = document.getElementById('sessions-today-hours');
const weekEl = document.getElementById('sessions-week-hours');
const activeEl = document.getElementById('sessions-active-now');
const usersEl = document.getElementById('sessions-users-count');
if (todayEl) todayEl.textContent = this.formatDuration(todaySec);
if (weekEl) weekEl.textContent = this.formatDuration(weekSec);
if (activeEl) activeEl.textContent = String(activeNow);
if (usersEl) usersEl.textContent = String(users);
},
renderSessionsSummary() {
const tbody = document.getElementById('sessions-summary-body');
if (!tbody) return;
const sessions = this.normalizeSessions(this.authSessionsData || []);
if (sessions.length === 0) {
tbody.innerHTML = '| Нет данных |
';
return;
}
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const weekStart = now.getTime() - (7 * 24 * 3600 * 1000);
const byUser = new Map();
sessions.forEach(s => {
const actor = s.actor || '—';
const row = byUser.get(actor) || {
actor,
todaySec: 0,
weekSec: 0,
count: 0,
lastSeenAt: null,
};
const startMs = new Date(s.started_at).getTime();
if (startMs >= todayStart) row.todaySec += (s.duration_sec || 0);
if (startMs >= weekStart) row.weekSec += (s.duration_sec || 0);
row.count += 1;
const lastSeenMs = s.last_seen_at ? new Date(s.last_seen_at).getTime() : 0;
if (!row.lastSeenAt || lastSeenMs > new Date(row.lastSeenAt).getTime()) row.lastSeenAt = s.last_seen_at || s.started_at;
byUser.set(actor, row);
});
const rows = [...byUser.values()]
.sort((a, b) => b.weekSec - a.weekSec)
.map(r => `
| ${this.escHtml(r.actor)} |
${this.formatDuration(r.todaySec)} |
${this.formatDuration(r.weekSec)} |
${r.count} |
${r.lastSeenAt ? new Date(r.lastSeenAt).toLocaleString('ru-RU') : '—'} |
`);
tbody.innerHTML = rows.join('');
},
renderSessionsList() {
const tbody = document.getElementById('sessions-list-body');
if (!tbody) return;
const sessions = this.normalizeSessions(this.authSessionsData || []).slice(0, 200);
if (sessions.length === 0) {
tbody.innerHTML = '| Нет данных |
';
return;
}
const rows = sessions.map(s => {
const start = s.started_at ? new Date(s.started_at).toLocaleString('ru-RU') : '—';
const end = s.ended_at ? new Date(s.ended_at).toLocaleString('ru-RU') : '—';
const status = s.effective_status === 'active'
? 'Активна'
: 'Завершена';
return `
| ${start} |
${end} |
${this.escHtml(s.actor || '—')} |
${this.formatDuration(s.duration_sec || 0)} |
${status} |
`;
});
tbody.innerHTML = rows.join('');
},
normalizeSessions(sessions) {
const nowMs = Date.now();
return (sessions || []).map(s => {
const startedMs = s.started_at ? new Date(s.started_at).getTime() : Date.now();
const endedMs = s.ended_at ? new Date(s.ended_at).getTime() : Date.now();
const computed = Math.max(0, Math.round((endedMs - startedMs) / 1000));
const lastSeenMs = s.last_seen_at ? new Date(s.last_seen_at).getTime() : startedMs;
const stale = (nowMs - lastSeenMs) > (2 * 60 * 1000);
const effectiveStatus = (s.status === 'active' && !stale) ? 'active' : 'closed';
return {
...s,
duration_sec: Math.max(0, parseInt(s.duration_sec || 0, 10) || computed),
started_at: s.started_at || new Date(startedMs).toISOString(),
effective_status: effectiveStatus,
};
}).sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
},
formatDuration(sec) {
const total = Math.max(0, Math.round(sec || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
return `${h}ч ${String(m).padStart(2, '0')}м`;
},
// ==========================================
// BACKUP / RESTORE
// ==========================================
// All localStorage keys used by the app
BACKUP_KEYS: [
'ro_calc_orders', 'ro_calc_order_items', 'ro_calc_settings',
'ro_calc_molds', 'ro_calc_employees', 'ro_calc_tasks',
'ro_calc_time_entries', 'ro_calc_chinaPurchases',
'ro_calc_warehouseItems', 'ro_calc_warehouseReservations',
'ro_calc_warehouseHistory', 'ro_calc_shipments',
'ro_calc_vacations', 'ro_calc_order_factuals', 'ro_calc_imports',
'ro_calc_colors',
'ro_calc_auth_accounts', 'ro_calc_auth_activity',
'ro_calc_auth_sessions',
'ro_calc_assembly_timing',
],
downloadBackup() {
const backup = {
_meta: {
app: 'RecycleObject',
version: typeof APP_VERSION !== 'undefined' ? APP_VERSION : 'unknown',
date: new Date().toISOString(),
browser: navigator.userAgent.slice(0, 80),
},
};
let totalRecords = 0;
this.BACKUP_KEYS.forEach(key => {
try {
const raw = localStorage.getItem(key);
if (raw) {
backup[key] = JSON.parse(raw);
if (Array.isArray(backup[key])) totalRecords += backup[key].length;
}
} catch (e) { /* skip corrupted */ }
});
// Also save autosave state
const autosaveKeys = ['ro_calc_autosave_draft', 'ro_calc_editing_order_id'];
autosaveKeys.forEach(key => {
try {
const raw = localStorage.getItem(key);
if (raw) backup[key] = JSON.parse(raw);
} catch (e) {
const raw = localStorage.getItem(key);
if (raw) backup[key] = raw;
}
});
const json = JSON.stringify(backup, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
a.href = url;
a.download = `RO_backup_${dateStr}.json`;
a.click();
URL.revokeObjectURL(url);
const sizeKb = Math.round(json.length / 1024);
const infoEl = document.getElementById('backup-info');
if (infoEl) {
infoEl.innerHTML = `Бэкап скачан: ${sizeKb} КБ, ${totalRecords} записей`;
}
App.toast(`Бэкап скачан (${sizeKb} КБ)`);
},
async restoreBackup(file) {
if (!file) return;
const infoEl = document.getElementById('restore-info');
try {
const text = await file.text();
const backup = JSON.parse(text);
if (!backup._meta || backup._meta.app !== 'RecycleObject') {
if (infoEl) infoEl.innerHTML = 'Это не бэкап Recycle Object';
return;
}
// Confirm
const backupDate = backup._meta.date ? new Date(backup._meta.date).toLocaleString('ru-RU') : '?';
const backupVer = backup._meta.version || '?';
if (!confirm(`Восстановить бэкап от ${backupDate} (${backupVer})?\n\nНовые данные будут ДОПОЛНЕНЫ к существующим.`)) {
return;
}
// Auto-backup current state first
this.autoBackup('pre-restore');
let restored = 0;
let merged = 0;
this.BACKUP_KEYS.forEach(key => {
if (!backup[key]) return;
const backupData = backup[key];
if (Array.isArray(backupData)) {
// Merge arrays by ID
let existing = [];
try { existing = JSON.parse(localStorage.getItem(key)) || []; } catch (e) { existing = []; }
const existingIds = new Set(existing.map(r => r.id));
let added = 0;
backupData.forEach(record => {
if (record.id && !existingIds.has(record.id)) {
existing.push(record);
added++;
}
});
if (added > 0) {
localStorage.setItem(key, JSON.stringify(existing));
merged += added;
}
restored++;
} else if (typeof backupData === 'object') {
// Settings: merge keys (don't overwrite existing)
let existing = {};
try { existing = JSON.parse(localStorage.getItem(key)) || {}; } catch (e) { existing = {}; }
const mergedSettings = { ...backupData, ...existing }; // existing takes priority
localStorage.setItem(key, JSON.stringify(mergedSettings));
restored++;
}
});
if (infoEl) {
infoEl.innerHTML = `Восстановлено: ${restored} коллекций, +${merged} новых записей`;
}
App.toast(`Бэкап восстановлен: +${merged} записей`);
// Reload page to apply
setTimeout(() => { location.reload(); }, 1500);
} catch (e) {
console.error('Restore error:', e);
if (infoEl) infoEl.innerHTML = `Ошибка: ${e.message}`;
}
// Reset file input
document.getElementById('backup-file-input').value = '';
},
autoBackup(reason) {
const backup = { _meta: { app: 'RecycleObject', version: typeof APP_VERSION !== 'undefined' ? APP_VERSION : '?', date: new Date().toISOString(), reason } };
this.BACKUP_KEYS.forEach(key => {
try {
const raw = localStorage.getItem(key);
if (raw) backup[key] = JSON.parse(raw);
} catch (e) { /* skip */ }
});
// Store up to 3 auto-backups
let autoBackups = [];
try { autoBackups = JSON.parse(localStorage.getItem('ro_calc_auto_backups')) || []; } catch (e) { autoBackups = []; }
autoBackups.unshift(backup);
if (autoBackups.length > 3) autoBackups = autoBackups.slice(0, 3);
try {
localStorage.setItem('ro_calc_auto_backups', JSON.stringify(autoBackups));
} catch (e) {
// localStorage full — remove oldest
autoBackups = autoBackups.slice(0, 1);
try { localStorage.setItem('ro_calc_auto_backups', JSON.stringify(autoBackups)); } catch (e2) { /* give up */ }
}
},
loadBackupTab() {
// Show auto-backups list
const listEl = document.getElementById('auto-backup-list');
if (!listEl) return;
let autoBackups = [];
try { autoBackups = JSON.parse(localStorage.getItem('ro_calc_auto_backups')) || []; } catch (e) {}
if (autoBackups.length === 0) {
listEl.innerHTML = 'Нет авто-бэкапов. Первый создастся при следующем обновлении.';
} else {
listEl.innerHTML = autoBackups.map((b, i) => {
const date = b._meta?.date ? new Date(b._meta.date).toLocaleString('ru-RU') : '?';
const ver = b._meta?.version || '?';
const reason = b._meta?.reason || '';
const orders = Array.isArray(b.ro_calc_orders) ? b.ro_calc_orders.length : 0;
return `
${date} · ${ver} · ${reason} · ${orders} заказов
`;
}).join('');
}
// Supabase status
const statusEl = document.getElementById('supabase-status-text');
const noteEl = document.getElementById('supabase-status-note');
const titleEl = document.getElementById('supabase-status-title');
const cardEl = document.getElementById('supabase-status-card');
if (statusEl) {
if (typeof isSupabaseReady === 'function' && isSupabaseReady()) {
statusEl.innerHTML = 'Облачная база подключена — данные синхронизируются между устройствами.';
if (titleEl) titleEl.textContent = 'Синхронизация данных';
if (noteEl) noteEl.textContent = 'Ничего делать не нужно. Этот режим нужен, чтобы работать с заказами и складом с разных устройств.';
if (cardEl) {
cardEl.style.borderLeft = '3px solid var(--green)';
cardEl.style.background = 'rgba(74, 179, 126, 0.06)';
}
} else {
statusEl.innerHTML = 'Облачная база не подключена — данные сохраняются только в этом браузере.';
if (titleEl) titleEl.textContent = 'Локальное хранение данных';
if (noteEl) noteEl.textContent = 'Это нормально, если работа идёт с одного компьютера. Для синхронизации между устройствами нужна облачная база.';
if (cardEl) {
cardEl.style.borderLeft = '3px solid var(--border)';
cardEl.style.background = 'var(--card)';
}
}
}
},
downloadAutoBackup(index) {
let autoBackups = [];
try { autoBackups = JSON.parse(localStorage.getItem('ro_calc_auto_backups')) || []; } catch (e) {}
const backup = autoBackups[index];
if (!backup) return;
const json = JSON.stringify(backup, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dateStr = backup._meta?.date ? backup._meta.date.slice(0, 10).replace(/-/g, '') : 'unknown';
a.href = url;
a.download = `RO_autobackup_${dateStr}.json`;
a.click();
URL.revokeObjectURL(url);
App.toast('Авто-бэкап скачан');
},
// ==========================================
// ASSEMBLY TIMING — Editable reference data
// ==========================================
TIMING_STORAGE_KEY: 'ro_calc_assembly_timing',
_defaultTimingData() {
return [
{ section: 'Отдельные операции', items: [
['Карабин', 4], ['Среднее кольцо', 5], ['Железный трос', 11],
['Шариковая цепочка', 5], ['Шнур (миланский, кожаный)', 10], ['Приклеить зеркало', 18],
]},
{ section: 'Сборки', items: [
['Кисточка + соед. кольцо', 34], ['Тег + соед. кольцо', 9],
['Трос + соед. кольцо + тег', 13],
['Вощ./кож. шнур 90см на изделие', 36],
['NFC: карабин + изделие + соед. кольцо', 16],
['Открывашка: шнур + наконечники (2шт)', 20],
['NFC: карабин + плоск. кольцо + тег + соед. кольцо', 30],
['Адресник: шарик. цепочка + изделие', 9],
['Карабин + шарик. цепочка + тег', 17],
['Милан. шнур + наконечники + вязка + 2 карабина', 50],
['Шарик. цепочка + гвоздь + бусина', 65],
['Колье: шарик. цепочка 90см + крепление', 128],
]},
{ section: 'Упаковка', items: [] },
];
},
getTimingData() {
try {
const raw = localStorage.getItem(this.TIMING_STORAGE_KEY);
if (raw) {
const data = JSON.parse(raw);
if (Array.isArray(data) && data.length > 0) return data;
}
} catch (e) {}
return this._defaultTimingData();
},
saveTimingData(data) {
localStorage.setItem(this.TIMING_STORAGE_KEY, JSON.stringify(data));
},
loadTimingTab() {
const container = document.getElementById('timing-editor');
if (!container) return;
const data = this.getTimingData();
let html = '';
data.forEach((group, gi) => {
html += ``;
});
html += ``;
container.innerHTML = html;
},
onTimingSectionName(gi, value) {
const data = this.getTimingData();
if (data[gi]) { data[gi].section = value.trim(); this.saveTimingData(data); }
},
onTimingItem(gi, ii, field, value) {
const data = this.getTimingData();
if (!data[gi] || !data[gi].items[ii]) return;
if (field === 'name') data[gi].items[ii][0] = value.trim();
if (field === 'sec') data[gi].items[ii][1] = parseInt(value) || 1;
this.saveTimingData(data);
this.loadTimingTab();
},
addTimingItem(gi) {
const data = this.getTimingData();
if (!data[gi]) return;
data[gi].items.push(['Новая операция', 10]);
this.saveTimingData(data);
this.loadTimingTab();
},
deleteTimingItem(gi, ii) {
const data = this.getTimingData();
if (!data[gi] || !data[gi].items[ii]) return;
data[gi].items.splice(ii, 1);
this.saveTimingData(data);
this.loadTimingTab();
},
addTimingSection() {
const data = this.getTimingData();
data.push({ section: 'Новый раздел', items: [] });
this.saveTimingData(data);
this.loadTimingTab();
},
deleteTimingSection(gi) {
const data = this.getTimingData();
if (!data[gi]) return;
if (data[gi].items.length > 0 && !confirm(`Удалить раздел "${data[gi].section}" с ${data[gi].items.length} операциями?`)) return;
data.splice(gi, 1);
this.saveTimingData(data);
this.loadTimingTab();
},
escHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
},
formatMoney(v) {
return `${(parseFloat(v) || 0).toLocaleString('ru-RU')} ₽`;
},
};