// ============================================= // 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 += `
`; (group.items || []).forEach(([name, sec], ii) => { const raw = 60 / (sec * 1.3); const pcsPerMin = raw >= 1 ? Math.floor(raw) : Math.round(raw * 10) / 10; html += ``; }); html += `
Операция Секунды шт/мин
${pcsPerMin}
`; }); 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')} ₽`; }, };