/
AxeOn
/
ProjectAi
Обзор
Документация
Войти
/
AxeOn
/
ProjectAi
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
static/main.js
6 268 строк
259 KB
Дмитрий
feat(economics-ui): build reactive manual surcharges UI with real-time math and auto-filling dropdowns
07 май 2026, 09:19
07 май 2026, 09:19
fa4d8f7
Код
Авторство
О чём код?
/* main.js — Office Planner SPA */ 'use strict'; // ── State ───────────────────────────────────────────────────────────────────── let allEmployees = []; let allTasks = []; let allDeadlines = []; // ── Helpers ─────────────────────────────────────────────────────────────────── /** Escape HTML special chars to prevent XSS. */ function esc(v) { if (v == null) return ''; return String(v) .replace(/&/g, '&').replace(/</g, '<') .replace(/>/g, '>').replace(/"/g, '"'); } /** Abbreviate full name to "Фамилия И.О." format. */ function formatFioToInitials(fullName) { if (!fullName) return ''; const parts = fullName.trim().split(/\s+/); if (parts.length === 1) return parts[0]; if (parts.length === 2) return `${parts[0]} ${parts[1][0].toUpperCase()}.`; return `${parts[0]} ${parts[1][0].toUpperCase()}.${parts[2][0].toUpperCase()}.`; } /** Build 1–2 letter initials from a full name. */ function initials(name) { return (name || '?').split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase(); } /** Deterministic avatar background colour based on name. */ const _AVATAR_PALETTE = [ '#1a3a6b', '#2e7d32', '#c62828', '#e65100', '#4a148c', '#00695c', '#37474f', '#1565c0', '#6a1b9a', '#ad1457', ]; function avatarBg(name) { let h = 0; for (let i = 0; i < (name || '').length; i++) h = (h * 31 + (name || '').charCodeAt(i)) >>> 0; return _AVATAR_PALETTE[h % _AVATAR_PALETTE.length]; } /** Set a status chip's state and text. */ function setChip(el, state, text) { el.className = `status-chip ${state}`; el.textContent = text; } function clearChip(el) { el.className = 'status-chip'; el.textContent = ''; } /** Generic empty / no-match message. */ function emptyMsg(text = 'Данные не найдены.') { return `<p style="color:var(--color-text-muted);font-size:13px;padding:16px 0;">${esc(text)}</p>`; } /** Generic error block. */ function errBlock(msg) { return `<p style="color:var(--color-danger);font-size:13px;padding:16px 0;"> Не удалось загрузить данные.<br> <code style="font-size:11px;">${esc(msg)}</code> </p>`; } /** Footer count line. */ function countLine(shown, total) { return `<p style="margin-top:10px;font-size:12px;color:var(--color-text-muted);"> Показано: <strong>${shown}</strong> из <strong>${total}</strong> </p>`; } // ── Tab navigation ──────────────────────────────────────────────────────────── const TABS = ['dashboard', 'employees', 'tasks', 'deadlines', 'vacations', 'calendar', 'workload', 'economics']; const tabButtons = document.querySelectorAll('.nav-tab'); const tabPanels = Object.fromEntries(TABS.map(t => [t, document.getElementById(`tab-${t}`)])); let activeTab = 'dashboard'; const tabLoaded = { dashboard: false, employees: false, tasks: false, deadlines: false, vacations: false, calendar: false, workload: false, economics: false }; // Maps each tab name to its data-loader. Extending tabs = one line here. const TAB_LOADERS = { dashboard: () => loadDashboard(), employees: () => loadEmployees(), tasks: () => loadTasks(), deadlines: () => loadDeadlines(), vacations: () => loadVacations(), calendar: () => loadCalendar(), workload: () => loadWorkload(), economics: () => initEconomics(), }; function switchTab(name) { if (!TABS.includes(name)) return; activeTab = name; tabButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); TABS.forEach(t => { const panel = tabPanels[t]; if (panel) panel.style.display = t === name ? '' : 'none'; }); if (!tabLoaded[name] && TAB_LOADERS[name]) TAB_LOADERS[name](); } tabButtons.forEach(btn => btn.addEventListener('click', () => switchTab(btn.dataset.tab))); // ── Dashboard ──────────────────────────────────────────────────────────────── async function loadDashboard() { if (tabLoaded.dashboard) return; tabLoaded.dashboard = true; const dashWorking = document.getElementById('dash-working'); const dashVacation = document.getElementById('dash-vacation'); const dashSick = document.getElementById('dash-sick'); const dashSlaToday = document.getElementById('dash-sla-today'); const dashOverdue = document.getElementById('dash-overdue'); const dashRevenue = document.getElementById('dash-revenue'); const dashMargin = document.getElementById('dash-margin'); const dashDeadlinesList = document.getElementById('dash-deadlines-list'); const dashVacationsList = document.getElementById('dash-vacations-list'); const dashDeptStats = document.getElementById('dash-dept-stats'); // Заглушки на время загрузки [dashWorking, dashVacation, dashSick, dashSlaToday, dashOverdue, dashRevenue, dashMargin].forEach(el => { if (el) el.textContent = '…'; }); try { const res = await fetch('/api/dashboard/summary'); if (!res.ok) throw new Error('API error'); const data = await res.json(); // ── Команда сегодня dashWorking.textContent = data.team_today?.working ?? '—'; dashVacation.textContent = data.team_today?.vacation ?? '—'; dashSick.textContent = data.team_today?.sick ?? '—'; // ── Дедлайны dashSlaToday.textContent = data.deadlines?.sla_today ?? '—'; dashOverdue.textContent = data.deadlines?.overdue ?? '—'; // ── Экономика dashRevenue.textContent = data.economics?.revenue ? formatNumber(data.economics.revenue) : '—'; dashMargin.textContent = data.economics?.margin ? `${data.economics.margin}%` : '—'; // ── Ближайшие дедлайны if (dashDeadlinesList) { if (data.upcoming_deadlines && data.upcoming_deadlines.length > 0) { dashDeadlinesList.innerHTML = data.upcoming_deadlines.map(dl => ` <div class="dash-list-item"> <div class="dash-list-title">${dl.case_number || '—'}</div> <div class="dash-list-meta"> <span class="dash-list-label">Эксперт:</span> ${dl.expert || '—'} </div> <div class="dash-list-meta"> <span class="dash-list-label">Срок:</span> <span class="dash-deadline-days${dl.days_left < 0 ? ' dash-overdue' : dl.days_left === 0 ? ' dash-urgent' : ''}">${dl.days_left} дн.</span> </div> </div> `).join(''); } else { dashDeadlinesList.innerHTML = '<p class="dash-empty-msg">Нет дедлайнов</p>'; } } // ── Отпуска if (dashVacationsList) { if (data.vacationing_employees && data.vacationing_employees.length > 0) { dashVacationsList.innerHTML = data.vacationing_employees.map(emp => ` <div class="dash-list-item"> <div class="dash-list-title">${emp}</div> <div class="dash-list-meta">В отпуске сегодня</div> </div> `).join(''); } else { dashVacationsList.innerHTML = '<p class="dash-empty-msg">Никто не в отпуске</p>'; } } // ── Статистика отделов if (dashDeptStats) { const depts = data.department_stats || {}; if (Object.keys(depts).length > 0) { dashDeptStats.innerHTML = Object.entries(depts) .sort((a, b) => (b[1].total || 0) - (a[1].total || 0)) .map(([dept, stats]) => { const total = stats.total || 0; const working = stats.working || 0; const pct = total > 0 ? Math.round((working / total) * 100) : 0; return ` <div class="dash-dept-card"> <div class="dash-dept-name">${dept}</div> <div class="dash-dept-bar"> <div class="dash-dept-bar-fill" style="width:${pct}%;"></div> </div> <div class="dash-dept-stats"> <span class="dash-dept-working">✓ ${working}</span> <span class="dash-dept-total">/ ${total}</span> </div> </div> `; }).join(''); } else { dashDeptStats.innerHTML = '<p class="dash-empty-msg">Нет данных</p>'; } } } catch (err) { console.error('Ошибка загрузки дашборда:', err); [dashWorking, dashVacation, dashSick, dashSlaToday, dashOverdue, dashRevenue, dashMargin].forEach(el => { if (el) el.textContent = '—'; }); if (dashDeadlinesList) dashDeadlinesList.innerHTML = '<p class="dash-empty-msg">Ошибка загрузки</p>'; if (dashVacationsList) dashVacationsList.innerHTML = '<p class="dash-empty-msg">Ошибка загрузки</p>'; if (dashDeptStats) dashDeptStats.innerHTML = '<p class="dash-empty-msg">Ошибка загрузки</p>'; } } // Вспомогательная функция форматирования чисел function formatNumber(num) { if (num >= 1000000) return (num / 1000000).toFixed(1) + ' млн'; if (num >= 1000) return (num / 1000).toFixed(1) + ' тыс'; return num.toString(); } // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // EMPLOYEES // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const empContainer = document.getElementById('employees-container'); const empChip = document.getElementById('chip-emp'); const empFilter = document.getElementById('filter-emp'); const empDeptSelect = document.getElementById('filter-emp-dept'); const empStatusSelect = document.getElementById('filter-emp-status'); const btnAddEmp = document.getElementById('btn-add-emp'); const btnEditEmp = document.getElementById('btn-edit-emp'); const btnDeleteEmp = document.getElementById('btn-delete-emp'); let selectedEmpId = null; // Populate department filter from API on first load async function loadEmpDepartments() { try { const r = await fetch('/api/employees/departments'); if (!r.ok) return; const depts = await r.json(); depts.forEach(d => { const opt = document.createElement('option'); opt.value = d; opt.textContent = d; empDeptSelect.appendChild(opt); // also populate modal dept select const opt2 = document.createElement('option'); opt2.value = d; opt2.textContent = d; document.getElementById('emp-dept').appendChild(opt2); }); } catch (_) { } } loadEmpDepartments(); function setSelectedEmp(id) { selectedEmpId = id; btnEditEmp.disabled = id === null; btnDeleteEmp.disabled = id === null; // highlight row empContainer.querySelectorAll('tr[data-emp-id]').forEach(tr => { tr.style.background = String(tr.dataset.empId) === String(id) ? 'var(--color-info-bg)' : ''; }); } function deptChip(dept) { if (!dept) return '<span style="color:var(--color-text-muted);font-size:11px;">—</span>'; return `<span class="status-chip running" style="display:inline-block;font-size:11px;padding:2px 8px;">${esc(dept)}</span>`; } function renderEmployeeRow(e) { const bg = e.is_fired ? 'var(--color-disabled)' : avatarBg(e.name); const faded = e.is_fired ? 'opacity:.5;' : ''; const contacts = [ e.email ? `<a href="mailto:${esc(e.email)}" class="emp-contact-link">✉ ${esc(e.email)}</a>` : '', e.phone ? `<span>📞 ${esc(e.phone)}</span>` : '', e.tg_id ? `<span>💬 ${esc(e.tg_id)}</span>` : '', ].filter(Boolean).join(''); const badges = [ e.is_manager ? '<span class="badge badge-manager">👑 Руководитель</span>' : '', e.is_fired ? '<span class="badge badge-reso" style="background:#ffebee;color:#c62828;border-color:#ef9a9a;">🔴 Уволен</span>' : '', e.is_temp ? '<span class="badge badge-temp">⏳ Временный</span>' : '', e.is_reso ? '<span class="badge badge-reso">🏢 РЕСО</span>' : '', ].filter(Boolean).join(' '); const mgmtCell = [ e.managers ? `<div style="font-size:12px;color:var(--color-text-muted);margin-bottom:${badges ? '4' : '0'}px;">${esc(e.managers)}</div>` : '', badges, ].filter(Boolean).join('') || '<span style="color:var(--color-text-muted);">—</span>'; return `<tr data-emp-id="${e.id}" style="cursor:pointer;${faded}"> <td class="col-name"><div class="emp-name-cell"> <div class="avatar" style="background:${bg};">${esc(initials(e.name))}</div> <span style="font-weight:600;">${esc(e.name)}</span> </div></td> <td class="col-dept">${deptChip(e.department)}</td> <td class="col-cat" style="font-size:12px;">${esc(e.category) || '—'}</td> <td class="col-contacts"><div class="emp-contacts">${contacts || '<span style="color:var(--color-text-muted);">—</span>'}</div></td> <td class="col-status" style="vertical-align:middle;padding:5px 8px;">${mgmtCell}</td> </tr>`; } function renderEmployees() { const q = (empFilter.value || '').trim().toLowerCase(); const dept = empDeptSelect.value; const stat = empStatusSelect.value; // 'active' | 'dismissed' | 'all' let rows = allEmployees; if (dept) rows = rows.filter(e => e.department === dept); if (stat === 'dismissed') rows = rows.filter(e => e.is_fired); if (q) rows = rows.filter(e => (e.name || '').toLowerCase().includes(q) || (e.department || '').toLowerCase().includes(q) || (e.managers || '').toLowerCase().includes(q) ); if (!rows.length) { empContainer.innerHTML = emptyMsg('Сотрудники не найдены.'); return; } empContainer.innerHTML = ` <table class="data-table"> <thead><tr> <th class="col-name">ФИО</th> <th class="col-dept">Отдел</th> <th class="col-cat">Категория</th> <th class="col-contacts">Контакты</th> <th class="col-status">Статус и Руководство</th> </tr></thead> <tbody>${rows.map(renderEmployeeRow).join('')}</tbody> </table> ${countLine(rows.length, allEmployees.length)}`; // Row click → select empContainer.querySelectorAll('tr[data-emp-id]').forEach(tr => { tr.addEventListener('click', () => { const id = parseInt(tr.dataset.empId, 10); setSelectedEmp(selectedEmpId === id ? null : id); }); tr.addEventListener('dblclick', () => { const id = parseInt(tr.dataset.empId, 10); setSelectedEmp(id); openEmpModal(id); }); }); } async function loadEmployees() { setChip(empChip, 'running', 'Загрузка…'); empContainer.innerHTML = ''; setSelectedEmp(null); try { const stat = empStatusSelect.value; const params = new URLSearchParams(); if (stat !== 'active') params.set('show_dismissed', 'true'); const r = await fetch('/api/employees?' + params); if (!r.ok) throw new Error(`HTTP ${r.status}`); allEmployees = await r.json(); tabLoaded.employees = true; renderEmployees(); setChip(empChip, 'done', `Загружено: ${allEmployees.length}`); } catch (e) { setChip(empChip, 'error', 'Ошибка'); empContainer.innerHTML = errBlock(e.message); } } empFilter.addEventListener('input', renderEmployees); empDeptSelect.addEventListener('change', renderEmployees); empStatusSelect.addEventListener('change', () => { tabLoaded.employees = false; loadEmployees(); }); // ── Custom managers multi-select dropdown ───────────────────────────────────── function _msdClose() { const dd = document.getElementById('managers-dropdown'); const wrap = document.getElementById('managers-multiselect'); if (dd) dd.style.display = 'none'; if (wrap) wrap.classList.remove('open'); } function _msdUpdateTitle() { const checked = [...document.querySelectorAll('#managers-dropdown input:checked')]; const titleEl = document.getElementById('managers-title'); const wrap = document.getElementById('managers-multiselect'); if (!titleEl) return; if (checked.length) { titleEl.textContent = checked.map(cb => cb.value).join(', '); wrap && wrap.classList.add('has-selection'); } else { titleEl.textContent = 'Выберите руководителей…'; wrap && wrap.classList.remove('has-selection'); } } function _msdResetTitle() { const titleEl = document.getElementById('managers-title'); const wrap = document.getElementById('managers-multiselect'); if (titleEl) titleEl.textContent = 'Выберите руководителей…'; if (wrap) wrap.classList.remove('has-selection'); } /** Build dropdown items; pre-check names from currentManagers CSV. */ function populateManagersPanel(currentManagers) { const dd = document.getElementById('managers-dropdown'); if (!dd) return; _msdClose(); _msdResetTitle(); const selected = new Set( (currentManagers || '').split(/,\s*/).map(s => s.trim()).filter(Boolean) ); const managers = allEmployees.filter(e => !e.is_fired && e.is_manager); if (!managers.length) { dd.innerHTML = '<div class="msd-empty">Нет сотрудников с флагом «Руководитель»</div>'; } else { dd.innerHTML = managers.map(emp => ` <label class="msd-item"> <input type="checkbox" value="${esc(emp.name)}"${selected.has(emp.name) ? ' checked' : ''}> <span>${esc(emp.name)}</span> ${emp.department ? `<span class="msd-dept">${esc(emp.department)}</span>` : ''} </label>`).join(''); dd.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.addEventListener('change', _msdUpdateTitle)); } _msdUpdateTitle(); } // Toggle open/close on header click document.getElementById('managers-header').addEventListener('click', () => { const dd = document.getElementById('managers-dropdown'); const wrap = document.getElementById('managers-multiselect'); if (dd.style.display === 'none') { dd.style.display = ''; wrap.classList.add('open'); } else { _msdClose(); } }); // Close when clicking outside the dropdown wrapper document.addEventListener('click', e => { const wrap = document.getElementById('managers-multiselect'); if (wrap && !wrap.contains(e.target)) _msdClose(); }, true); // ── Employee CRUD modal ─────────────────────────────────────────────────────── const empModal = document.getElementById('modal-employee'); const empModalForm = document.getElementById('form-employee'); const empModalTitle = document.getElementById('emp-modal-title'); const empModalSubmit = document.getElementById('emp-modal-submit'); const empIsActiveToggle = document.getElementById('emp-is-active'); const empFiredDate = document.getElementById('emp-fired-date'); const empFiredDateBlock = document.getElementById('emp-fired-date-block'); let editingEmpId = null; empIsActiveToggle.addEventListener('change', () => { const fired = !empIsActiveToggle.checked; empFiredDateBlock.style.display = fired ? '' : 'none'; if (fired && !empFiredDate.value) { empFiredDate.value = new Date().toISOString().split('T')[0]; } }); function empModalClose() { empModal.classList.add('hidden'); empModalForm.reset(); empIsActiveToggle.checked = true; empFiredDateBlock.style.display = 'none'; empFiredDate.value = ''; _msdClose(); _msdResetTitle(); editingEmpId = null; } async function openEmpModal(empId = null) { editingEmpId = empId; empModalTitle.textContent = empId ? 'Редактировать сотрудника' : 'Новый сотрудник'; empModalForm.reset(); empIsActiveToggle.checked = true; empFiredDateBlock.style.display = 'none'; empFiredDate.value = ''; if (empId) { try { const cached = allEmployees.find(e => e.id === empId); const e = cached || await fetch('/api/employees?').then(r => r.json()).then(arr => arr.find(x => x.id === empId)); if (e) { document.getElementById('emp-name').value = e.name || ''; document.getElementById('emp-dept').value = e.department || ''; document.getElementById('emp-schedule').value = e.schedule_pattern || '5/2_mon_fri'; document.getElementById('emp-category').value = e.category || ''; document.getElementById('emp-email').value = e.email || ''; document.getElementById('emp-phone').value = e.phone || ''; document.getElementById('emp-tg').value = e.tg_id || ''; document.getElementById('emp-birth').value = e.birth_date || ''; populateManagersPanel(e.managers || ''); document.getElementById('emp-is-manager').checked = !!e.is_manager; document.getElementById('emp-is-temp').checked = !!e.is_temp; document.getElementById('emp-is-reso').checked = !!e.is_reso; empIsActiveToggle.checked = !e.is_fired; if (e.is_fired) { empFiredDateBlock.style.display = ''; empFiredDate.value = e.fired_date || ''; } } } catch (_) { } } if (!empId) populateManagersPanel(''); empModal.classList.remove('hidden'); document.getElementById('emp-name').focus(); } document.getElementById('emp-modal-close').addEventListener('click', empModalClose); document.getElementById('emp-modal-cancel').addEventListener('click', empModalClose); empModal.addEventListener('click', e => { if (e.target === empModal) empModalClose(); }); btnAddEmp.addEventListener('click', () => openEmpModal(null)); btnEditEmp.addEventListener('click', () => { if (selectedEmpId) openEmpModal(selectedEmpId); }); empModalForm.addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('emp-name').value.trim(); if (!name) { document.getElementById('emp-name').focus(); return; } const isFired = !empIsActiveToggle.checked; const body = { name, department: document.getElementById('emp-dept').value || null, schedule_pattern: document.getElementById('emp-schedule').value || '5/2_mon_fri', category: document.getElementById('emp-category').value || null, email: document.getElementById('emp-email').value.trim() || null, phone: document.getElementById('emp-phone').value.trim() || null, tg_id: document.getElementById('emp-tg').value.trim() || null, birth_date: document.getElementById('emp-birth').value || null, managers: [...document.querySelectorAll('#managers-dropdown input:checked')].map(cb => cb.value).join(', ') || null, is_manager: document.getElementById('emp-is-manager').checked ? 1 : 0, is_temp: document.getElementById('emp-is-temp').checked ? 1 : 0, is_reso: document.getElementById('emp-is-reso').checked ? 1 : 0, is_fired: isFired ? 1 : 0, fired_date: isFired ? (empFiredDate.value || null) : null, }; empModalSubmit.disabled = true; empModalSubmit.textContent = 'Сохранение…'; try { const url = editingEmpId ? `/api/employees/${editingEmpId}` : '/api/employees'; const method = editingEmpId ? 'PUT' : 'POST'; const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); empModalClose(); tabLoaded.employees = false; await loadEmployees(); } catch (err) { alert(`Ошибка сохранения:\n${err.message}`); } finally { empModalSubmit.disabled = false; empModalSubmit.textContent = 'Сохранить'; } }); // ── Specialization Modal (employee services) ────────────────────────────────── let _specEmpId = null; let _allCalcServices = []; // cached list from API const specModal = document.getElementById('modal-specialization'); const specModalTitle = document.getElementById('spec-modal-title'); const specTbody = document.getElementById('spec-services-tbody'); const specAddRowBtn = document.getElementById('spec-add-row-btn'); const specModalSave = document.getElementById('spec-modal-save'); const specModalClose = document.getElementById('spec-modal-close'); const specModalCancel = document.getElementById('spec-modal-cancel'); /** Fetch available calc services from the distribution attributes endpoint. */ async function _ensureCalcServices() { if (_allCalcServices.length) return; try { const r = await fetch('/api/distribution/attributes'); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); _allCalcServices = data.service_types || []; } catch (_) { _allCalcServices = []; } } /** Render one service row. */ function _renderSpecRow(svc, index) { const options = _allCalcServices.map(s => `<option value="${esc(s)}"${svc === s ? ' selected' : ''}>${esc(s)}</option>` ).join(''); return `<tr data-spec-idx="${index}"> <td style="width:1%;white-space:nowrap;padding:4px 6px;vertical-align:middle;"> <button type="button" class="btn-icon btn-icon-danger spec-remove-btn" data-spec-idx="${index}" title="Удалить">×</button> </td> <td style="padding:4px 6px;vertical-align:middle;"> <select class="form-select form-select-sm spec-svc-select" data-spec-idx="${index}" style="width:100%;min-width:200px;"> <option value="">— выберите услугу —</option> ${options} </select> </td> <td style="width:1%;white-space:nowrap;padding:4px 6px;vertical-align:middle;"> <span style="font-size:11px;color:var(--color-text-muted);">#${index + 1}</span> </td> </tr>`; } /** Render the full services table. */ function _renderSpecTable(svcTypes) { if (!svcTypes || !svcTypes.length) { specTbody.innerHTML = '<tr><td colspan="3" style="text-align:center;padding:24px;color:var(--color-text-muted);font-size:13px;">Услуги не добавлены. Нажмите «+ Добавить услугу».</td></tr>'; return; } specTbody.innerHTML = svcTypes.map((svc, i) => _renderSpecRow(svc, i)).join(''); } /** Collect current service values from the table. */ function _collectSpecServices() { const selects = specTbody.querySelectorAll('.spec-svc-select'); return Array.from(selects) .map(sel => sel.value.trim()) .filter(v => v !== ''); } /** Open the specialization modal for a given employee. */ async function openSpecModal(empId) { _specEmpId = empId; const emp = allEmployees.find(e => e.id === empId); specModalTitle.textContent = `Специализация: ${emp ? esc(emp.name) : 'ID ' + empId}`; await _ensureCalcServices(); let svcTypes = []; if (emp && emp.svc_types) { try { svcTypes = JSON.parse(emp.svc_types); if (!Array.isArray(svcTypes)) svcTypes = []; } catch (_) { svcTypes = []; } } _renderSpecTable(svcTypes); specModal.classList.remove('hidden'); } function closeSpecModal() { specModal.classList.add('hidden'); _specEmpId = null; } // Wire specialization modal events document.getElementById('emp-modal-spec-btn').addEventListener('click', () => { if (!editingEmpId) { alert('Сначала сохраните сотрудника.'); return; } openSpecModal(editingEmpId); }); specModalClose.addEventListener('click', closeSpecModal); specModalCancel.addEventListener('click', closeSpecModal); specModal.addEventListener('click', e => { if (e.target === specModal) closeSpecModal(); }); // Add row specAddRowBtn.addEventListener('click', () => { const current = _collectSpecServices(); current.push(''); // empty slot _renderSpecTable(current); // Auto-focus the last select const lastSel = specTbody.querySelector('.spec-svc-select:last-child'); if (lastSel) lastSel.focus(); }); // Remove row (delegated) specTbody.addEventListener('click', e => { const btn = e.target.closest('.spec-remove-btn'); if (!btn) return; const idx = parseInt(btn.dataset.specIdx, 10); const current = _collectSpecServices(); current.splice(idx, 1); _renderSpecTable(current); }); // Save — fetch current employee data, merge svc_types, PUT specModalSave.addEventListener('click', async () => { if (!_specEmpId) return; const services = _collectSpecServices(); const svcTypesJson = JSON.stringify(services); specModalSave.disabled = true; specModalSave.textContent = 'Сохранение…'; try { // Fetch current employee data to build a complete PUT body const emp = allEmployees.find(e => e.id === _specEmpId); if (!emp) throw new Error('Сотрудник не найден в кеше'); const body = { name: emp.name || '', department: emp.department || null, schedule_pattern: emp.schedule_pattern || '5/2_mon_fri', category: emp.category || null, email: emp.email || null, phone: emp.phone || null, tg_id: emp.tg_id || null, birth_date: emp.birth_date || null, managers: emp.managers || null, is_manager: emp.is_manager || 0, is_temp: emp.is_temp || 0, is_reso: emp.is_reso || 0, is_fired: emp.is_fired || 0, fired_date: emp.fired_date || null, svc_types: svcTypesJson, }; const r = await fetch(`/api/employees/${_specEmpId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${r.status}`); } // Update local cache emp.svc_types = svcTypesJson; closeSpecModal(); } catch (err) { alert(`Ошибка сохранения специализации:\n${err.message}`); } finally { specModalSave.disabled = false; specModalSave.textContent = 'Сохранить'; } }); btnDeleteEmp.addEventListener('click', async () => { if (!selectedEmpId) return; const emp = allEmployees.find(e => e.id === selectedEmpId); if (!confirm(`Удалить сотрудника «${emp?.name || selectedEmpId}»?\nЭто действие необратимо.`)) return; try { const r = await fetch(`/api/employees/${selectedEmpId}`, { method: 'DELETE' }); if (!r.ok && r.status !== 204) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${r.status}`); } tabLoaded.employees = false; await loadEmployees(); } catch (err) { alert(`Ошибка удаления:\n${err.message}`); } }); // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // TASKS // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const tasksContainer = document.getElementById('tasks-container'); const tasksChip = document.getElementById('chip-tasks'); const tasksFilter = document.getElementById('filter-tasks'); const btnRefreshTasks = document.getElementById('btn-refresh-tasks'); /** Map TaskStatus string → CSS chip class. */ function taskStatusChip(status) { if (!status) return `<span class="status-chip running" style="display:inline-block;">Новая</span>`; const s = status.toLowerCase(); let cls = 'running'; if (s.includes('выполн')) cls = 'done'; else if (s.includes('работ')) cls = 'running'; else if (s.includes('обсужд') || s.includes('запрос')) cls = 'pending'; return `<span class="status-chip ${cls}" style="display:inline-block;font-size:11px;">${esc(status)}</span>`; } function priorityLabel(p) { if (!p) return ''; const cls = p === 'High' ? 'priority-high' : p === 'Low' ? 'priority-low' : 'priority-medium'; const label = p === 'High' ? '▲ Высокий' : p === 'Low' ? '▼ Низкий' : '● Средний'; return `<span class="${cls}">${label}</span>`; } /** Format YYYY-MM-DD → DD.MM.YYYY, highlight overdue in red. */ function dueDateCell(d) { if (!d) return '—'; const dt = new Date(d); const today = new Date(); today.setHours(0, 0, 0, 0); const display = dt.toLocaleDateString('ru-RU'); const overdue = !isNaN(dt) && dt < today; return overdue ? `<span style="color:var(--color-danger);font-weight:600;">⚠ ${display}</span>` : `<span>${display}</span>`; } function renderTaskRow(t) { return `<tr> <td style="max-width:260px;"> <div style="font-weight:600;font-size:13px;">${esc(t.title)}</div> ${t.client_name ? `<div style="font-size:11px;color:var(--color-text-muted);">${esc(t.client_name)}</div>` : ''} </td> <td>${taskStatusChip(t.status)}</td> <td>${priorityLabel(t.priority)}</td> <td>${t.assignee ? `<div style="display:flex;align-items:center;gap:7px;"> <div class="avatar" style="width:26px;height:26px;font-size:10px;">${esc(initials(t.assignee))}</div> <span style="font-size:13px;">${esc(t.assignee)}</span> </div>` : '<span style="color:var(--color-text-muted);">—</span>'}</td> <td>${dueDateCell(t.due_date)}</td> <td style="max-width:180px;font-size:12px;color:var(--color-text-muted);">${esc(t.comment) || '—'}</td> </tr>`; } function renderTasks() { const q = tasksFilter.value.trim().toLowerCase(); const rows = q ? allTasks.filter(t => (t.title || '').toLowerCase().includes(q) || (t.client_name || '').toLowerCase().includes(q) || (t.assignee || '').toLowerCase().includes(q)) : allTasks; if (!rows.length) { tasksContainer.innerHTML = emptyMsg('Задачи не найдены.'); return; } tasksContainer.innerHTML = ` <table class="data-table"> <thead><tr> <th>Задача</th><th>Статус</th><th>Приоритет</th> <th>Исполнитель</th><th>Срок</th><th>Комментарий</th> </tr></thead> <tbody>${rows.map(renderTaskRow).join('')}</tbody> </table> ${countLine(rows.length, allTasks.length)}`; } async function loadTasks() { setChip(tasksChip, 'running', 'Загрузка…'); btnRefreshTasks.disabled = true; tasksContainer.innerHTML = ''; try { const r = await fetch('/api/tasks'); if (!r.ok) throw new Error(`HTTP ${r.status}`); allTasks = await r.json(); tabLoaded.tasks = true; renderTasks(); setChip(tasksChip, 'done', `Загружено: ${allTasks.length}`); } catch (e) { setChip(tasksChip, 'error', 'Ошибка'); tasksContainer.innerHTML = errBlock(e.message); } finally { btnRefreshTasks.disabled = false; } } btnRefreshTasks.addEventListener('click', () => { tabLoaded.tasks = false; loadTasks(); }); tasksFilter.addEventListener('input', renderTasks); // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // DEADLINES // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // DEADLINES // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const dlContainer = document.getElementById('deadlines-container'); const dlChip = document.getElementById('chip-dl'); const dlFilter = document.getElementById('filter-dl'); const dlThreshold = document.getElementById('dl-threshold'); const dlBadge = document.getElementById('dl-badge-unassigned'); // "Select all" delegation — survives table re-renders; only touches visible rows dlContainer.addEventListener('change', e => { if (e.target.id === 'dl-select-all') { const checked = e.target.checked; dlContainer.querySelectorAll('tbody tr').forEach(tr => { if (tr.style.display !== 'none') { const cb = tr.querySelector('.dl-row-cb'); if (cb) cb.checked = checked; } }); } }); let dlSubtab = 'all'; let dlStats = null; // ── Helpers ─────────────────────────────────────────────────────────────────── // Service-type badge colour mapping (checked in order, first match wins) const _SVC_BADGE_MAP = [ [/осаго/i, 'osago'], [/судебн/i, 'court'], [/смет/i, 'smeta'], [/расчет/i, 'calc'], [/имущ/i, 'estate'], [/оценк/i, 'eval'], ]; function formatServiceType(raw) { if (!raw) return '<span style="color:var(--color-text-muted);">—</span>'; const title = raw.split(/\s+/) .filter(Boolean) .map(w => w[0].toUpperCase() + w.slice(1).toLowerCase()) .join(' '); const mod = (_SVC_BADGE_MAP.find(([re]) => re.test(raw)) || [])[1] || 'default'; return `<span class="svc-badge svc-badge--${mod}">${esc(title)}</span>`; } function deadlineChip(days) { if (days == null) return '<span style="color:var(--color-text-muted);font-size:12px;">—</span>'; let bg, color, border; if (days > 14) { bg = '#fef2f2'; color = '#991b1b'; border = '#fecaca'; } else if (days > 7) { bg = '#fff7ed'; color = '#9a3412'; border = '#fed7aa'; } else if (days > 3) { bg = '#fefce8'; color = '#713f12'; border = '#fef08a'; } else { bg = '#f0fdf4'; color = '#166534'; border = '#bbf7d0'; } return `<span style="display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;background:${bg};color:${color};border:1px solid ${border};white-space:nowrap;">${days} дн.</span>`; } function _dlAgeCls(days) { if (days == null) return ''; if (days > 14) return 'dl-td-age--urgent'; if (days > 7) return 'dl-td-age--aging'; if (days > 3) return 'dl-td-age--normal'; return 'dl-td-age--fresh'; } function commentCell(comment) { if (!comment || comment === 'На расчете') return '<span style="color:var(--color-text-muted);font-size:12px;">На расчёте</span>'; return `<span style="font-size:12px;font-style:italic;color:var(--color-warning-text);">${esc(comment)}</span>`; } function isUnassigned(c) { return !(c.expert_calculation || '').trim().replace(/^nan$/i, '').replace(/^None$/i, ''); } function isSysCase(caseNum) { return /^SYS/i.test(caseNum || ''); } // ── Sub-tab switching ───────────────────────────────────────────────────────── function switchDlSubtab(name) { dlSubtab = name; document.querySelectorAll('.dl-subtab').forEach(b => b.classList.toggle('active', b.dataset.subtab === name) ); document.getElementById('dl-view-all').style.display = name === 'all' ? '' : 'none'; document.getElementById('dl-view-unassigned').style.display = name === 'unassigned' ? '' : 'none'; document.getElementById('dl-view-stats').style.display = name === 'stats' ? '' : 'none'; if (name === 'unassigned') renderUnassigned(); if (name === 'stats' && !dlStats) loadDlStats(); } document.querySelectorAll('.dl-subtab').forEach(btn => btn.addEventListener('click', () => switchDlSubtab(btn.dataset.subtab)) ); // ── Badge helper ────────────────────────────────────────────────────────────── function updateUnassignedBadge() { const threshold = parseInt(dlThreshold.value, 10); const n = allDeadlines.filter( c => isUnassigned(c) && !isSysCase(c.case_number) && (c.deadline || 0) >= threshold ).length; dlBadge.textContent = n; dlBadge.className = 'dl-badge' + (n > 0 ? '' : ' empty'); return n; } // ── Threshold dropdown ──────────────────────────────────────────────────────── function applyThresholdFilter() { const threshold = parseInt(dlThreshold.value, 10); const tbody = dlContainer.querySelector('tbody'); if (!tbody) return; tbody.querySelectorAll('tr[data-deadline]').forEach(tr => { const days = parseInt(tr.dataset.deadline, 10); const hide = days < threshold; tr.style.display = hide ? 'none' : ''; if (hide) { const cb = tr.querySelector('.dl-row-cb'); if (cb) cb.checked = false; } }); // reset select-all header checkbox when filter changes const selAll = document.getElementById('dl-select-all'); if (selAll) selAll.checked = false; } dlThreshold.addEventListener('change', () => { updateUnassignedBadge(); if (dlSubtab === 'unassigned') renderUnassigned(); applyThresholdFilter(); }); // ── "All cases" table ───────────────────────────────────────────────────────── function renderDeadlineRow(c) { return `<tr data-deadline="${c.deadline ?? 0}"> <td class="dl-td-cb"><input type="checkbox" class="dl-row-cb" value="${c.id}"></td> <td style="font-weight:700;font-family:var(--font-mono);font-size:13px;"> <span title="Нажмите для просмотра карточки" style="cursor:default;">${esc(c.case_number)}</span> </td> <td class="${_dlAgeCls(c.deadline)}">${deadlineChip(c.deadline)}</td> <td>${commentCell(c.comment)}</td> <td>${c.expert_calculation ? `<div style="display:flex;align-items:center;gap:7px;"> <div class="avatar green" style="width:26px;height:26px;font-size:10px;">${esc(initials(c.expert_calculation))}</div> <span style="font-size:13px;">${esc(c.expert_calculation)}</span> </div>` : '<span style="color:var(--color-text-muted);">Не назначен</span>'}</td> <td style="font-size:12px;color:var(--color-text-muted);">${esc(c.region) || '—'}</td> <td>${formatServiceType(c.department)}</td> <td style="font-size:12px;color:var(--color-text-muted);">${esc(c.create_date) || '—'}</td> </tr>`; } function renderDeadlines() { const q = dlFilter.value.trim().toLowerCase(); const rows = q ? allDeadlines.filter(c => (c.case_number || '').toLowerCase().includes(q) || (c.expert_calculation || '').toLowerCase().includes(q) || (c.region || '').toLowerCase().includes(q) || (c.comment || '').toLowerCase().includes(q)) : allDeadlines; if (!rows.length) { dlContainer.innerHTML = emptyMsg('Дела не найдены. Загрузите данные в десктопном приложении.'); return; } dlContainer.innerHTML = ` <table class="data-table"> <thead><tr> <th class="dl-th-cb"><input type="checkbox" id="dl-select-all" title="Выбрать все"></th> <th>Дело №</th><th>Срок</th><th>Статус</th> <th>Эксперт</th><th>Регион</th><th>Тип услуги</th><th>Дата создания</th> </tr></thead> <tbody>${rows.map(renderDeadlineRow).join('')}</tbody> </table> ${countLine(rows.length, allDeadlines.length)}`; applyThresholdFilter(); } // ── Deadlines sidebar: idle vs busy experts ─────────────────────────────────── async function updateDeadlinesSidebar() { const elIdle = document.getElementById('dl-sidebar-idle'); const elBusy = document.getElementById('dl-sidebar-busy'); if (!elIdle || !elBusy) return; try { const r = await fetch('/api/deadlines/workload'); if (!r.ok) return; const data = await r.json(); // Widget 1: idle experts (0 cases, not on vacation today) if (data.idle.length === 0) { elIdle.innerHTML = '<em class="dl-sb-all-busy">Все сотрудники загружены</em>'; } else { elIdle.innerHTML = data.idle .map(n => `<div class="dl-sb-idle-name">${esc(shortName(n))}</div>`) .join(''); } // Widget 2: busy experts grouped by department (accordion + progress bars) if (Object.keys(data.busy).length === 0) { elBusy.innerHTML = '<em style="color:var(--color-text-muted);">Нет назначенных дел</em>'; } else { elBusy.innerHTML = Object.entries(data.busy).map(([dept, experts]) => { const rows = experts.map(e => { const hasLimit = e.limit !== null && e.limit > 0; const pct = hasLimit ? (e.count / e.limit) * 100 : 0; const over = hasLimit && e.count >= e.limit; const colorKey = !hasLimit ? '' : pct >= 100 ? 'danger' : pct >= 80 ? 'warn' : 'ok'; if (!hasLimit) { return `<div class="dl-sb-busy-row"> <span class="dl-sb-busy-name">${esc(shortName(e.name))}</span> <span class="dl-sb-count">${e.count}</span> </div>`; } return `<div class="dl-sb-busy-row dl-sb-busy-row--bar"> <div class="dl-sb-busy-top"> <span class="dl-sb-busy-name${over ? ' dl-sb-name-over' : ''}">${esc(shortName(e.name))}</span> <span class="dl-sb-count dl-sb-count--${colorKey}">${e.count}/${e.limit}</span> </div> <div class="dl-sb-progress"> <div class="dl-sb-progress-fill dl-sb-progress--${colorKey}" style="width:${Math.min(pct, 100).toFixed(1)}%"></div> </div> </div>`; }).join(''); return `<details class="dl-sb-dept-group" open> <summary class="dl-sb-dept-header">${esc(dept)}</summary> ${rows} </details>`; }).join(''); } } catch (_) { } } function updateDlStatsBar() { const total = allDeadlines.length; const assigned = allDeadlines.filter(c => !isUnassigned(c) && !isSysCase(c.case_number)).length; // Derive upload date from load_date field (YYYY-MM-DD or YYYY-MM-DD_N) const rawDate = allDeadlines.reduce((mx, c) => (c.load_date > mx ? c.load_date : mx), ''); const isoDate = rawDate.substring(0, 10); let dateLabel = ''; if (isoDate.length === 10) { const [y, m, d] = isoDate.split('-'); dateLabel = `Данные от: ${d}.${m}.${y}`; } document.getElementById('dl-stat-loaded').textContent = `Загружено: ${total}`; document.getElementById('dl-stat-assigned').textContent = `В работе: ${assigned}`; document.getElementById('dl-stat-date').textContent = dateLabel; } async function loadDeadlines() { setChip(dlChip, 'running', 'Загрузка…'); dlContainer.innerHTML = ''; try { const r = await fetch('/api/deadlines'); if (!r.ok) throw new Error(`HTTP ${r.status}`); allDeadlines = await r.json(); tabLoaded.deadlines = true; renderDeadlines(); updateUnassignedBadge(); dlStats = null; clearChip(dlChip); updateDlStatsBar(); updateDeadlinesSidebar(); _fetchUploadDates(); } catch (e) { setChip(dlChip, 'error', 'Ошибка'); dlContainer.innerHTML = errBlock(e.message); } } dlFilter.addEventListener('input', renderDeadlines); // ── "Requires assignment" view ──────────────────────────────────────────────── function renderUnassigned() { const threshold = parseInt(dlThreshold.value, 10); const qualifying = allDeadlines.filter( c => isUnassigned(c) && !isSysCase(c.case_number) && (c.deadline || 0) >= threshold ); const hintEl = document.getElementById('dl-unassigned-hint'); if (hintEl) hintEl.textContent = `Порог: ${threshold} дн. · Найдено: ${qualifying.length} дел без назначенного эксперта`; const con = document.getElementById('dl-unassigned-container'); if (!qualifying.length) { con.innerHTML = emptyMsg('Нет дел, требующих назначения при текущем пороге. 🎉'); return; } con.innerHTML = ` <table class="data-table" id="dl-unassigned-table"> <thead><tr> <th class="dl-th-cb"><input type="checkbox" id="dl-unassigned-select-all" title="Выделить всё"></th> <th>Дело №</th><th>Срок</th><th>Регион</th> <th>Тип услуги</th><th>Оплата / Тип</th> <th>Кандидат на расчет</th><th>Дата создания</th> </tr></thead> <tbody>${qualifying.map(c => ` <tr class="dl-unassigned-row" style="cursor:pointer;" data-case-num="${esc(c.case_number)}" data-load-date="${esc(c.load_date || '')}" data-deadline="${c.deadline || 0}" data-region="${esc(c.region || '')}" data-dept="${esc(c.department || '')}" data-payment="${esc(c.payment_info || '')}"> <td class="dl-td-cb"><input type="checkbox" class="dl-unassigned-cb" value="${esc(c.case_number)}" title="${esc(c.case_number)}"></td> <td style="font-weight:700;font-family:var(--font-mono);font-size:13px;"> <span title="Нажмите для просмотра карточки" style="cursor:pointer;">${esc(c.case_number)}</span> </td> <td class="${_dlAgeCls(c.deadline)}">${deadlineChip(c.deadline)}</td> <td style="font-size:12px;">${esc(c.region) || '—'}</td> <td>${formatServiceType(c.department)}</td> <td style="font-size:11px;color:var(--color-text-muted);">${esc((c.payment_info || '—').slice(0, 45))}</td> <td style="font-size:12px;${c.candidate_expert ? 'color:var(--color-primary);font-weight:600;' : 'color:var(--color-text-muted);'}">${esc(c.candidate_expert || '—')}</td> <td style="font-size:12px;color:var(--color-text-muted);">${esc(c.create_date) || '—'}</td> </tr>`).join('')} </tbody> </table> ${countLine(qualifying.length, allDeadlines.length)}`; // Select-all checkbox delegation const selAll = con.querySelector('#dl-unassigned-select-all'); if (selAll) { selAll.addEventListener('change', () => { con.querySelectorAll('.dl-unassigned-cb').forEach(cb => cb.checked = selAll.checked); }); } con.querySelectorAll('.dl-unassigned-row').forEach(row => row.addEventListener('click', e => { if (e.target.classList.contains('dl-unassigned-cb')) return; openAssignDrawer(row.dataset); }) ); // Enable send buttons only when at least one candidate is present const hasCandidates = qualifying.some(c => c.candidate_expert); btnSendAssignments.disabled = !hasCandidates; btnNotifyOperatorsAssign.disabled = !hasCandidates; } // ── Side drawer ─────────────────────────────────────────────────────────────── const assignOverlay = document.getElementById('dl-assign-overlay'); const assignExpert = document.getElementById('dl-assign-expert'); const assignComment = document.getElementById('dl-assign-comment'); const assignSubmit = document.getElementById('dl-assign-submit'); const assignErrEl = document.getElementById('dl-assign-error'); let _assigningCase = null; function openAssignDrawer(data) { _assigningCase = data; document.getElementById('dl-assign-case-num').textContent = `№ ${data.caseNum}`; document.getElementById('dl-assign-meta').textContent = [data.deadline ? `Срок: ${data.deadline} дн.` : null, data.dept, data.region] .filter(Boolean).join(' · '); assignComment.value = ''; assignExpert.value = ''; assignSubmit.disabled = true; if (assignErrEl) { assignErrEl.style.display = 'none'; assignErrEl.textContent = ''; } // Populate expert list once if (assignExpert.options.length <= 1) { (allEmployees || []) .filter(e => !e.is_fired) .sort((a, b) => a.name.localeCompare(b.name, 'ru')) .forEach(e => { const opt = document.createElement('option'); opt.value = e.name; opt.textContent = e.name; assignExpert.appendChild(opt); }); } assignOverlay.classList.remove('hidden'); } function closeAssignDrawer() { assignOverlay.classList.add('hidden'); _assigningCase = null; } assignExpert.addEventListener('change', () => { assignSubmit.disabled = !assignExpert.value; }); document.getElementById('dl-assign-close').addEventListener('click', closeAssignDrawer); assignOverlay.addEventListener('click', e => { if (e.target === assignOverlay) closeAssignDrawer(); }); assignSubmit.addEventListener('click', async () => { if (!_assigningCase || !assignExpert.value) return; const caseNum = _assigningCase.caseNum; const loadDate = _assigningCase.loadDate; assignSubmit.disabled = true; assignSubmit.textContent = 'Назначение…'; if (assignErrEl) { assignErrEl.style.display = 'none'; } try { const r = await fetch('/api/deadlines/assign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ case_number: caseNum, load_date: loadDate, expert_name: assignExpert.value, comment: assignComment.value.trim() || null, }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); // Animate row out of the unassigned table const sel = `tr[data-case-num="${CSS.escape(caseNum)}"]`; const row = document.querySelector(sel); if (row) { row.classList.add('dl-row-remove'); setTimeout(() => row.remove(), 450); } // Update in-memory cache so badge stays accurate const idx = allDeadlines.findIndex(c => c.case_number === caseNum); if (idx >= 0) allDeadlines[idx].expert_calculation = assignExpert.value; updateUnassignedBadge(); closeAssignDrawer(); setChip(dlChip, 'done', `Назначено: ${assignExpert.value}`); } catch (err) { if (assignErrEl) { assignErrEl.textContent = err.message; assignErrEl.style.display = 'block'; } } finally { assignSubmit.disabled = false; assignSubmit.textContent = 'Назначить'; } }); // ── Stats view ──────────────────────────────────────────────────────────────── async function loadDlStats() { const con = document.getElementById('dl-stats-container'); con.innerHTML = '<p style="color:var(--color-text-muted);padding:20px 0">Загрузка статистики…</p>'; try { const r = await fetch('/api/deadlines/stats'); if (!r.ok) throw new Error(`HTTP ${r.status}`); dlStats = await r.json(); renderDlStats(); } catch (e) { con.innerHTML = errBlock(e.message); } } function renderDlStats() { if (!dlStats) return; const { current_date, prev_date, dl_sorted, departments, totals } = dlStats; const con = document.getElementById('dl-stats-container'); const infoEl = document.getElementById('dl-stats-info'); if (infoEl) infoEl.textContent = current_date ? `Дата: ${current_date}` + (prev_date ? ` · Предыдущая: ${prev_date}` : ' · Предыдущая загрузка не найдена') : ''; if (!departments || !departments.length) { con.innerHTML = emptyMsg('Нет данных для статистики.'); return; } function dlCls(dl, count) { if (!count) return ''; if (dl >= 8) return 'dl-cell-urgent'; if (dl >= 4) return 'dl-cell-warning'; return 'dl-cell-ok'; } const dayColStyle = 'style="padding:0.2rem !important;text-align:center;font-size:0.85rem;"'; const firstColStyle = 'style="white-space:normal;word-wrap:break-word;min-width:150px;width:auto;"'; let html = '<div style="overflow-x:auto;"><table class="dl-stats-table table-sm"><thead>'; // Row 1: group headers html += '<tr>'; html += `<th rowspan="2" ${firstColStyle} style="text-align:left;vertical-align:bottom;">Отдел НЭ</th>`; if (dl_sorted.length) html += `<th colspan="${dl_sorted.length}" style="text-align:center;">← Дней на расчёте →</th>`; html += `<th rowspan="2" class="dl-col-total" ${dayColStyle} style="vertical-align:bottom;">Всего</th>`; html += '<th colspan="2" style="text-align:center;">Изменения</th>'; html += '</tr>'; // Row 2: individual deadline values + change labels html += '<tr>'; dl_sorted.forEach(dl => { html += `<th ${dayColStyle}>${dl}</th>`; }); html += `<th ${dayColStyle}>↓ Закр.</th><th ${dayColStyle}>↑ Нов.</th>`; html += '</tr></thead><tbody>'; departments.forEach(d => { html += '<tr>'; html += `<td ${firstColStyle}>${esc(d.dept)}</td>`; dl_sorted.forEach(dl => { const v = d.dl_counts[String(dl)] || 0; html += `<td class="${dlCls(dl, v)}" ${dayColStyle}>${v || ''}</td>`; }); html += `<td class="dl-col-total" ${dayColStyle}>${d.total || ''}</td>`; html += `<td class="${d.closed ? 'dl-cell-closed' : ''}" ${dayColStyle}>${d.closed ? `↓ ${d.closed}` : ''}</td>`; html += `<td class="${d.new ? 'dl-cell-new' : ''}" ${dayColStyle}>${d.new ? `↑ ${d.new}` : ''}</td>`; html += '</tr>'; }); if (totals) { html += '<tr class="dl-stats-total">'; html += `<td ${firstColStyle}>ИТОГО</td>`; dl_sorted.forEach(dl => { html += `<td ${dayColStyle}>${totals.dl_counts[String(dl)] || ''}</td>`; }); html += `<td class="dl-col-total" ${dayColStyle}>${totals.total || ''}</td>`; html += `<td ${dayColStyle}>${totals.closed ? `↓ ${totals.closed}` : ''}</td>`; html += `<td ${dayColStyle}>${totals.new ? `↑ ${totals.new}` : ''}</td>`; html += '</tr>'; } html += '</tbody></table></div>'; con.innerHTML = html; } // ── Send statistics report modal ────────────────────────────────────────────── const modalSendReport = document.getElementById('modal-send-report'); const chipDlStats = document.getElementById('chip-dl-stats'); const sendReportSubmit = document.getElementById('send-report-submit'); const sendReportPreview = document.getElementById('send-report-recipients-preview'); const tplStatsSubjectEl = document.getElementById('tpl-stats-subject'); const tplStatsBodyEl = document.getElementById('tpl-stats-body'); const STATS_DEFAULT_SUBJECT = 'Статистика дедлайнов за {ПЕРИОД}'; const STATS_DEFAULT_BODY = 'Отчёт по статистике дедлайнов за период {ПЕРИОД}.\n\n' + '{СТАТИСТИКА}\n\n' + 'Сформировано автоматически.'; function closeStatReportModal() { modalSendReport.classList.add('hidden'); } document.getElementById('send-report-close').addEventListener('click', closeStatReportModal); document.getElementById('send-report-cancel').addEventListener('click', closeStatReportModal); modalSendReport.addEventListener('click', e => { if (e.target === modalSendReport) closeStatReportModal(); }); document.getElementById('btn-send-report').addEventListener('click', async () => { sendReportPreview.innerHTML = 'Загрузка адресов…'; sendReportSubmit.disabled = false; tplStatsSubjectEl.value = STATS_DEFAULT_SUBJECT; tplStatsBodyEl.value = STATS_DEFAULT_BODY; modalSendReport.classList.remove('hidden'); try { const [rAddr, tpl] = await Promise.all([ fetch('/api/settings/statistics-emails'), loadTemplate('stats_report'), ]); if (rAddr.ok) { const d = _migrateEmailResponse(await rAddr.json()); const haTo = d.to.length > 0; sendReportPreview.innerHTML = haTo ? `<div><span style="color:var(--color-text-muted);font-size:11px;">Кому:</span> ${d.to.map(a => `<a href="mailto:${esc(a)}" class="gs-preview-addr">${esc(a)}</a>`).join(', ')}</div>` + (d.cc.length ? `<div style="margin-top:3px;"><span style="color:var(--color-text-muted);font-size:11px;">Копия:</span> ${d.cc.map(a => `<a href="mailto:${esc(a)}" class="gs-preview-addr gs-preview-addr-cc">${esc(a)}</a>`).join(', ')}</div>` : '') : '⚠ Адреса не настроены — откройте ⚙ Настройки и заполните поле «Отчёты по статистике».'; sendReportSubmit.disabled = !haTo; } if (tpl) { tplStatsSubjectEl.value = tpl.subject || STATS_DEFAULT_SUBJECT; tplStatsBodyEl.value = tpl.body || STATS_DEFAULT_BODY; } } catch (_) { sendReportPreview.textContent = 'Ошибка загрузки адресов'; } }); sendReportSubmit.addEventListener('click', async () => { sendReportSubmit.disabled = true; sendReportSubmit.textContent = 'Отправка…'; setChip(chipDlStats, 'running', 'Отправка…'); try { const r = await fetch('/api/deadlines/send-statistics-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject_tpl: tplStatsSubjectEl.value, body_tpl: tplStatsBodyEl.value, }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); await saveTemplate('stats_report', tplStatsSubjectEl.value, tplStatsBodyEl.value); closeStatReportModal(); setChip(chipDlStats, 'done', `Отправлено: ${result.sent} адр.`); } catch (err) { setChip(chipDlStats, 'error', err.message); } finally { sendReportSubmit.disabled = false; sendReportSubmit.textContent = 'Отправить'; } }); // ── Admin Excel report button + modal ───────────────────────────────────────── const btnReportAdmin = document.getElementById('btn-report-admin'); const chipDlAll = document.getElementById('chip-dl-all'); const modalExcelReport = document.getElementById('modal-excel-report'); const excelReportSubmit = document.getElementById('excel-report-submit'); const excelReportPreviewEl = document.getElementById('excel-report-recipients-preview'); const tplExcelSubjectEl = document.getElementById('tpl-excel-subject'); const tplExcelBodyEl = document.getElementById('tpl-excel-body'); const EXCEL_DEFAULT_SUBJECT = 'Отчёт по дедлайнам (Порог: {ПОРОГ} дн.) на {ДАТА}'; const EXCEL_DEFAULT_BODY = 'Отчёт по дедлайнам с порогом {ПОРОГ} дней сформирован {ДАТА}.\n' + 'Файл во вложении.'; function closeExcelReportModal() { modalExcelReport.classList.add('hidden'); } document.getElementById('excel-report-close').addEventListener('click', closeExcelReportModal); document.getElementById('excel-report-cancel').addEventListener('click', closeExcelReportModal); modalExcelReport.addEventListener('click', e => { if (e.target === modalExcelReport) closeExcelReportModal(); }); btnReportAdmin.addEventListener('click', async () => { excelReportPreviewEl.innerHTML = 'Загрузка адресов…'; excelReportSubmit.disabled = false; tplExcelSubjectEl.value = EXCEL_DEFAULT_SUBJECT; tplExcelBodyEl.value = EXCEL_DEFAULT_BODY; modalExcelReport.classList.remove('hidden'); try { const [rAddr, tpl] = await Promise.all([ fetch('/api/settings/all-tasks-emails'), loadTemplate('excel_report'), ]); if (rAddr.ok) { const d = _migrateEmailResponse(await rAddr.json()); const haTo = d.to.length > 0; const ccParam = d.cc.length ? `?cc=${encodeURIComponent(d.cc.join(','))}` : ''; excelReportPreviewEl.innerHTML = haTo ? `<div><span style="color:var(--color-text-muted);font-size:11px;">Кому:</span> ${d.to.map(a => `<a href="mailto:${esc(a)}${ccParam}" class="gs-preview-addr">${esc(a)}</a>`).join(', ')}</div>` + (d.cc.length ? `<div style="margin-top:3px;"><span style="color:var(--color-text-muted);font-size:11px;">Копия:</span> ${d.cc.map(a => `<a href="mailto:${esc(a)}" class="gs-preview-addr gs-preview-addr-cc">${esc(a)}</a>`).join(', ')}</div>` : '') : '⚠ Адреса не настроены — откройте ⚙ Настройки и заполните поле «Отчёты и напоминания».'; excelReportSubmit.disabled = !haTo; } if (tpl) { tplExcelSubjectEl.value = tpl.subject || EXCEL_DEFAULT_SUBJECT; tplExcelBodyEl.value = tpl.body || EXCEL_DEFAULT_BODY; } } catch (_) { excelReportPreviewEl.textContent = 'Ошибка загрузки адресов'; } }); excelReportSubmit.addEventListener('click', async () => { excelReportSubmit.disabled = true; excelReportSubmit.textContent = 'Генерация…'; setChip(chipDlAll, 'running', 'Генерация отчёта…'); try { const threshold = parseInt(dlThreshold.value, 10); const r = await fetch('/api/deadlines/send-admin-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threshold, subject_tpl: tplExcelSubjectEl.value, body_tpl: tplExcelBodyEl.value, }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); await saveTemplate('excel_report', tplExcelSubjectEl.value, tplExcelBodyEl.value); closeExcelReportModal(); setChip(chipDlAll, 'done', `Отчёт отправлен (${result.sent || 0} адр.)`); setTimeout(() => clearChip(chipDlAll), 5000); } catch (err) { setChip(chipDlAll, 'error', err.message); } finally { excelReportSubmit.disabled = false; excelReportSubmit.textContent = 'Отправить'; } }); // ── Master Send — открытие модала ──────────────────────────────────────────── const masterSendModal = document.getElementById('master-send-modal'); const confirmMasterSend = document.getElementById('confirm-master-send'); function closeMasterSendModal() { masterSendModal.classList.add('hidden'); } document.getElementById('master-send-close').addEventListener('click', closeMasterSendModal); document.getElementById('master-send-cancel').addEventListener('click', closeMasterSendModal); masterSendModal.addEventListener('click', e => { if (e.target === masterSendModal) closeMasterSendModal(); }); document.getElementById('btn-master-send').addEventListener('click', () => masterSendModal.classList.remove('hidden')); // ── Master Send — оркестрация трёх рассылок ─────────────────────────────────── confirmMasterSend.addEventListener('click', async () => { confirmMasterSend.disabled = true; const origText = confirmMasterSend.innerHTML; confirmMasterSend.textContent = 'Отправка…'; setChip(chipDlAll, 'running', 'Массовая рассылка…'); const threshold = parseInt(dlThreshold.value, 10) || 3; const errors = []; // 1. Excel-отчёт администраторам try { const r = await fetch('/api/deadlines/send-admin-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ threshold, subject_tpl: EXCEL_DEFAULT_SUBJECT, body_tpl: EXCEL_DEFAULT_BODY, }), }); const res = await r.json().catch(() => ({})); if (!r.ok) errors.push(`Отчёт администраторам: ${res.detail || r.status}`); } catch (e) { errors.push(`Отчёт администраторам: ${e.message}`); } // 2. Уведомления экспертам (нужен предварительный fetch данных) try { const rData = await fetch('/api/emails/reminders-data'); if (rData.ok) { const remindData = await rData.json(); if (remindData.length) { const r = await fetch('/api/emails/send-reminders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject: EMAIL_DEFAULT_SUBJECT, body_template: EMAIL_DEFAULT_BODY, expert_names: remindData.map(d => d.expert_name), }), }); const res = await r.json().catch(() => ({})); if (!r.ok) errors.push(`Уведомления экспертам: ${res.detail || r.status}`); } } else { errors.push(`Уведомления экспертам: HTTP ${rData.status}`); } } catch (e) { errors.push(`Уведомления экспертам: ${e.message}`); } // 3. Отчёт по статистике try { const r = await fetch('/api/deadlines/send-statistics-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject_tpl: STATS_DEFAULT_SUBJECT, body_tpl: STATS_DEFAULT_BODY, }), }); const res = await r.json().catch(() => ({})); if (!r.ok) errors.push(`Статистика: ${res.detail || r.status}`); } catch (e) { errors.push(`Статистика: ${e.message}`); } closeMasterSendModal(); if (errors.length) { setChip(chipDlAll, 'error', `Частично отправлено. Ошибки: ${errors.join(' | ')}`); setTimeout(() => clearChip(chipDlAll), 12000); } else { setChip(chipDlAll, 'done', '✅ Все данные успешно отправлены!'); setTimeout(() => clearChip(chipDlAll), 6000); } confirmMasterSend.disabled = false; confirmMasterSend.innerHTML = origText; }); // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // DEADLINES — EXCEL UPLOAD MODAL // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const dlModal = document.getElementById('modal-add-deadline'); const dlModalForm = document.getElementById('form-add-deadline'); const dlSubmitBtn = document.getElementById('modal-submit'); const btnAddDl = document.getElementById('btn-add-dl'); const uploadZone = document.getElementById('upload-zone'); const fileInput = document.getElementById('modal-file'); const fileNameLabel = document.getElementById('upload-filename'); function openDeadlineModal() { dlModal.classList.remove('hidden'); } function closeDeadlineModal() { dlModal.classList.add('hidden'); dlModalForm.reset(); fileNameLabel.textContent = ''; dlSubmitBtn.disabled = true; dlSubmitBtn.textContent = 'Загрузить'; } function setUploadFile(file) { if (!file) return; fileInput.files = (() => { const dt = new DataTransfer(); dt.items.add(file); return dt.files; })(); fileNameLabel.textContent = file.name; dlSubmitBtn.disabled = false; } uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); }); uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('dragover'); if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', () => { if (fileInput.files[0]) { fileNameLabel.textContent = fileInput.files[0].name; dlSubmitBtn.disabled = false; } }); const btnSmartDist = document.getElementById('btn-smart-dist'); const btnSendAssignments = document.getElementById('btn-send-assignments'); const btnNotifyOperatorsAssign = document.getElementById('btn-notify-operators-assign'); const chipDlUnassigned = document.getElementById('chip-dl-unassigned'); const btnDistSettings = document.getElementById('btn-dist-settings'); // ── Distribution preview modal ───────────────────────────────────────────────── const modalDistPreview = document.getElementById('modal-dist-preview'); const distPreviewTbody = document.getElementById('dist-preview-tbody'); const distPreviewHint = document.getElementById('dist-preview-hint'); const btnDistPreviewApply = document.getElementById('dist-preview-apply'); const btnDistPreviewCancel = document.getElementById('dist-preview-cancel'); const distPreviewAllCb = document.getElementById('dist-preview-all'); function closeDistPreview() { modalDistPreview.classList.add('hidden'); } document.getElementById('dist-preview-close').addEventListener('click', closeDistPreview); btnDistPreviewCancel.addEventListener('click', closeDistPreview); modalDistPreview.addEventListener('click', e => { if (e.target === modalDistPreview) closeDistPreview(); }); distPreviewAllCb.addEventListener('change', () => { distPreviewTbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = distPreviewAllCb.checked; }); }); btnDistPreviewApply.addEventListener('click', async () => { const checked = Array.from(distPreviewTbody.querySelectorAll('input[type=checkbox]:checked')); if (!checked.length) return; const assignments = checked.map(cb => ({ case_number: cb.dataset.caseNum, load_date: cb.dataset.loadDate, expert: cb.dataset.expert, })); btnDistPreviewApply.disabled = true; const origText = btnDistPreviewApply.textContent; btnDistPreviewApply.textContent = 'Применение…'; try { const r = await fetch('/api/distribution/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assignments }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); closeDistPreview(); setChip(chipDlUnassigned, 'done', `Кандидаты записаны: ${result.applied}`); btnSendAssignments.disabled = false; btnNotifyOperatorsAssign.disabled = false; tabLoaded.deadlines = false; await loadDeadlines(); // После обновления данных перерисовываем активную вкладку if (dlSubtab === 'unassigned') renderUnassigned(); } catch (err) { alert(`Ошибка применения:\n${err.message}`); btnDistPreviewApply.disabled = false; btnDistPreviewApply.textContent = origText; } }); // ── Distribution rules matrix modal ─────────────────────────────────────────── const modalDistSettings = document.getElementById('modal-dist-settings'); const distRulesTbody = document.getElementById('dist-rules-tbody'); const btnDistRuleAdd = document.getElementById('btn-dist-rule-add'); const btnDistRuleEdit = document.getElementById('btn-dist-rule-edit'); const btnDistRuleDelete = document.getElementById('btn-dist-rule-delete'); const btnDistLimits = document.getElementById('btn-dist-limits'); let _distRulesData = []; let _selectedRuleId = null; function closeDistSettings() { modalDistSettings.classList.add('hidden'); } document.getElementById('dist-settings-close').addEventListener('click', closeDistSettings); document.getElementById('dist-settings-cancel').addEventListener('click', closeDistSettings); modalDistSettings.addEventListener('click', e => { if (e.target === modalDistSettings) closeDistSettings(); }); function _ruleCell(field) { if (!field || !field.values || !field.values.length) { return '<span class="dist-cell-empty">—</span>'; } const icon = field.type === 'deny' ? '🚫' : '⭐'; const cls = field.type === 'deny' ? 'dist-cell-deny' : 'dist-cell-only'; return `<span class="${cls}">${icon} ${esc(field.values.join(', '))}</span>`; } function _ruleActiveCell(ruleId, isActive) { const title = isActive ? 'Активно — нажмите, чтобы отключить' : 'Отключено — нажмите, чтобы включить'; return `<div class="rule-toggle-wrap"> <label class="rule-toggle-label" title="${title}"> <input type="checkbox" class="rule-toggle" data-rule-id="${ruleId}"${isActive ? ' checked' : ''}> <span class="rule-toggle-slider"></span> </label> </div>`; } function _renderDistRules() { if (!_distRulesData.length) { distRulesTbody.innerHTML = '<tr><td colspan="7" class="dist-rules-empty">Правила маршрутизации не настроены</td></tr>'; _applyDistScroll(); return; } distRulesTbody.innerHTML = _distRulesData.map(r => ` <tr class="dist-rule-row${r.id === _selectedRuleId ? ' selected' : ''}${r.is_active ? '' : ' dist-rule-inactive'}" data-rule-id="${r.id}"> <td style="font-weight:600;">${esc(r.expert_name)}</td> <td>${_ruleCell(r.service_types)}</td> <td>${_ruleCell(r.regions)}</td> <td>${_ruleCell(r.risks)}</td> <td>${_ruleCell(r.prefixes)}</td> <td class="align-middle text-center">${_ruleActiveCell(r.id, r.is_active)}</td> <td class="text-center align-middle"> <button type="button" class="btn-duplicate-rule" data-rule-id="${r.id}" title="Дублировать правило"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1z"/> <path d="M2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"/> </svg> </button> </td> </tr>`).join(''); _applyDistScroll(); distRulesTbody.querySelectorAll('.dist-rule-row').forEach(row => { row.addEventListener('click', e => { if (e.target.closest('.rule-toggle-label')) return; // тумблер не выделяет строку if (e.target.closest('.btn-duplicate-rule')) return; // кнопка дублирования не выделяет строку _selectedRuleId = parseInt(row.dataset.ruleId, 10); btnDistRuleEdit.disabled = false; btnDistRuleDelete.disabled = false; _saveDistScroll(); _renderDistRules(); }); row.addEventListener('dblclick', e => { if (e.target.closest('.rule-toggle-label')) return; // тумблер не открывает модал if (e.target.closest('.btn-duplicate-rule')) return; // кнопка дублирования не открывает модал _selectedRuleId = parseInt(row.dataset.ruleId, 10); openRuleEdit('edit'); }); }); } // ── Scroll position preservation for dist-rules-table-wrap ──────────────────── let _savedRulesScrollTop = 0; const _distTableWrap = document.querySelector('.dist-rules-table-wrap'); function _saveDistScroll() { _savedRulesScrollTop = _distTableWrap ? _distTableWrap.scrollTop : 0; } function _applyDistScroll() { if (!_distTableWrap || _savedRulesScrollTop <= 0) return; // Capture and clear immediately — prevents double-fire if called twice before any rAF runs const target = _savedRulesScrollTop; _savedRulesScrollTop = 0; // Double rAF: outer schedules after current paint, inner fires after browser has laid out the new DOM requestAnimationFrame(() => { requestAnimationFrame(() => { _distTableWrap.scrollTop = target; }); }); } // Делегированный обработчик тумблера is_active distRulesTbody.addEventListener('change', async e => { const toggle = e.target.closest('.rule-toggle'); if (!toggle) return; const ruleId = parseInt(toggle.dataset.ruleId, 10); toggle.disabled = true; _saveDistScroll(); try { const r = await fetch(`/api/distribution/rules/${ruleId}/toggle`, { method: 'PATCH' }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); const rule = _distRulesData.find(x => x.id === ruleId); if (rule) rule.is_active = result.is_active; _renderDistRules(); } catch (err) { toggle.checked = !toggle.checked; // откат при ошибке alert(`Ошибка переключения: ${err.message}`); } finally { toggle.disabled = false; } }); // Делегированный обработчик кнопки "Дублировать" distRulesTbody.addEventListener('click', e => { const btn = e.target.closest('.btn-duplicate-rule'); if (!btn) return; e.stopPropagation(); // prevent row selection from triggering const ruleId = parseInt(btn.dataset.ruleId, 10); const rule = _distRulesData.find(r => r.id === ruleId); if (!rule) return; const copy = structuredClone(rule); copy.id = null; openRuleEdit(copy).catch(err => alert(`Ошибка дублирования:\n${err.message}`)); }); async function loadDistributionRules() { distRulesTbody.innerHTML = '<tr><td colspan="7" class="dist-rules-empty">Загрузка…</td></tr>'; try { const r = await fetch('/api/distribution/rules'); _distRulesData = await r.json().catch(() => []); _renderDistRules(); // _renderDistRules already calls _applyDistScroll internally } catch (e) { distRulesTbody.innerHTML = `<tr><td colspan="7" class="dist-rules-empty" style="color:var(--color-danger);">Ошибка: ${esc(e.message)}</td></tr>`; } } // ── Email chip manager ──────────────────────────────────────────────────────── // State: { allTasks: {to:[], cc:[]}, assignments: {to:[], cc:[]}, statistics: {to:[], cc:[]} } const _gsEmails = { allTasks: { to: [], cc: [] }, assignments: { to: [], cc: [] }, statistics: { to: [], cc: [] }, }; function _splitEmails(str) { return (str || '').split(/[,;\s]+/).map(s => s.trim()).filter(s => s.includes('@')); } function _migrateEmailResponse(d) { // backward-compat: old API returned {emails:...}; new returns {to:..., cc:...} if ('emails' in d && !('to' in d)) return { to: _splitEmails(d.emails), cc: [] }; return { to: _splitEmails(d.to), cc: _splitEmails(d.cc) }; } function _renderChips(listEl, emails, isCC) { if (!listEl) return; listEl.innerHTML = emails.length ? emails.map(e => `<span class="gs-chip${isCC ? ' gs-chip-cc' : ' gs-chip-to'}">${esc(e)}<button class="gs-chip-del" data-email="${esc(e)}" title="Удалить">×</button></span>` ).join('') : `<span class="gs-chip-empty">—</span>`; } function _refreshChips(key) { const s = _gsEmails[key]; _renderChips(document.getElementById(`chips-${_keyToId(key)}-to`), s.to, false); _renderChips(document.getElementById(`chips-${_keyToId(key)}-cc`), s.cc, true); } function _keyToId(key) { return { allTasks: 'all-tasks', assignments: 'assignments', statistics: 'statistics' }[key]; } function _addEmail(key, type, email) { const clean = email.trim().toLowerCase(); if (!clean.includes('@')) return false; const list = _gsEmails[key][type]; if (list.includes(clean)) return false; list.push(clean); _refreshChips(key); return true; } function _removeEmail(key, type, email) { const list = _gsEmails[key][type]; const idx = list.indexOf(email); if (idx === -1) return; list.splice(idx, 1); _refreshChips(key); } // Wire add buttons + Enter key for all 6 inputs (3 cards × To/CC) [ ['all-tasks', 'allTasks'], ['assignments', 'assignments'], ['statistics', 'statistics'], ].forEach(([idPfx, key]) => { ['to', 'cc'].forEach(type => { const input = document.getElementById(`input-${idPfx}-${type}`); const btn = document.getElementById(`btn-add-${idPfx}-${type}`); if (!input || !btn) return; const tryAdd = () => { if (_addEmail(key, type, input.value)) input.value = ''; }; btn.addEventListener('click', tryAdd); input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); tryAdd(); } }); }); }); // Chip delete delegation (one listener per chip-list container per card) document.getElementById('modal-dist-settings').addEventListener('click', e => { const del = e.target.closest('.gs-chip-del'); if (!del) return; const listEl = del.closest('.gs-chip-list'); if (!listEl) return; const idAttr = listEl.id; // e.g. "chips-all-tasks-to" const [, , keyPart, , type] = idAttr.split('-'); // unreliable for multi-word keys — use dataset instead // Parse key + type from element ID: "chips-{id}-{to|cc}" const m = idAttr.match(/^chips-(.+)-(to|cc)$/); if (!m) return; const keyMap = { 'all-tasks': 'allTasks', assignments: 'assignments', statistics: 'statistics' }; const key = keyMap[m[1]]; if (!key) return; _removeEmail(key, m[2], del.dataset.email); }); async function _saveGsEmailCard(btnId, key, endpoint) { const btn = document.getElementById(btnId); if (!btn) return; btn.disabled = true; try { const s = _gsEmails[key]; const r = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: s.to.join(', '), cc: s.cc.join(', ') }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); btn.textContent = '✓ Сохранено'; setTimeout(() => { btn.textContent = 'Сохранить'; btn.disabled = false; }, 2000); } catch (err) { alert(`Ошибка сохранения: ${err.message}`); btn.disabled = false; } } btnDistSettings.addEventListener('click', async () => { _selectedRuleId = null; btnDistRuleEdit.disabled = true; btnDistRuleDelete.disabled = true; modalDistSettings.classList.remove('hidden'); loadDistributionRules(); try { const [r1, r2, r3] = await Promise.all([ fetch('/api/settings/all-tasks-emails'), fetch('/api/settings/assignments-emails'), fetch('/api/settings/statistics-emails'), ]); if (r1.ok) { _gsEmails.allTasks = _migrateEmailResponse(await r1.json()); } if (r2.ok) { _gsEmails.assignments = _migrateEmailResponse(await r2.json()); } if (r3.ok) { _gsEmails.statistics = _migrateEmailResponse(await r3.json()); } } catch (_) { } _refreshChips('allTasks'); _refreshChips('assignments'); _refreshChips('statistics'); }); document.getElementById('btn-dist-save-admin-emails').addEventListener('click', () => _saveGsEmailCard('btn-dist-save-admin-emails', 'allTasks', '/api/settings/all-tasks-emails') ); document.getElementById('btn-gs-save-assignments-cc').addEventListener('click', () => _saveGsEmailCard('btn-gs-save-assignments-cc', 'assignments', '/api/settings/assignments-emails') ); document.getElementById('btn-gs-save-expert-control').addEventListener('click', () => _saveGsEmailCard('btn-gs-save-expert-control', 'statistics', '/api/settings/statistics-emails') ); // ── Rule add/edit form ──────────────────────────────────────────────────────── const modalRuleEdit = document.getElementById('modal-rule-edit'); const ruleEditTitle = document.getElementById('rule-edit-title'); const ruleExpertSelect = document.getElementById('rule-expert-select'); const ruleServiceType = document.getElementById('rule-service-type'); const ruleServiceValues = document.getElementById('rule-service-values'); const ruleRegionType = document.getElementById('rule-region-type'); const ruleRegionValues = document.getElementById('rule-region-values'); const ruleRiskType = document.getElementById('rule-risk-type'); const ruleRiskValues = document.getElementById('rule-risk-values'); const rulePrefixType = document.getElementById('rule-prefix-type'); const rulePrefixValues = document.getElementById('rule-prefix-values'); const btnRuleEditSave = document.getElementById('rule-edit-save'); function closeRuleEdit() { modalRuleEdit.classList.add('hidden'); } document.getElementById('rule-edit-close').addEventListener('click', closeRuleEdit); document.getElementById('rule-edit-cancel').addEventListener('click', closeRuleEdit); modalRuleEdit.addEventListener('click', e => { if (e.target === modalRuleEdit) closeRuleEdit(); }); // "Все / Сброс" toggle + "Все" / "Сброс" buttons for checkbox lists (delegated to modal container) modalRuleEdit.addEventListener('click', e => { const btn = e.target.closest('[data-list][data-action]'); if (!btn) return; const list = document.getElementById(btn.dataset.list); if (!list) return; const cbs = list.querySelectorAll('input[type="checkbox"]'); if (!cbs.length) return; if (btn.dataset.action === 'toggle') { // Toggle: if all are checked → uncheck all; otherwise → check all const allChecked = [...cbs].every(cb => cb.checked); cbs.forEach(cb => { cb.checked = !allChecked; }); } else { const checked = btn.dataset.action === 'all'; cbs.forEach(cb => { cb.checked = checked; }); } }); let _distAttrs = null; async function _ensureDistAttrs() { if (_distAttrs) return _distAttrs; try { const r = await fetch('/api/distribution/attributes'); if (r.ok) _distAttrs = await r.json(); } catch (_) { } if (!_distAttrs) _distAttrs = { regions: [], service_types: [], risks: [], prefixes: [] }; return _distAttrs; } function _populateCbList(container, options, checkedSet) { if (!options || !options.length) { container.innerHTML = '<em style="color:var(--color-text-muted);font-size:12px;">Нет данных</em>'; return; } container.innerHTML = options.map((opt, i) => { const id = `${container.id}_cb_${i}`; const chk = checkedSet && checkedSet.has(opt) ? ' checked' : ''; return `<label for="${id}"><input type="checkbox" id="${id}" value="${esc(opt)}"${chk}> ${esc(opt)}</label>`; }).join(''); } function _getTypeVal(groupEl) { const inp = groupEl.querySelector('input[type="radio"]:checked'); return inp ? inp.value : 'none'; } function _setTypeVal(groupEl, val) { const inp = groupEl.querySelector(`input[type="radio"][value="${val}"]`); if (inp) inp.checked = true; } function _fillField(typeEl, cbListEl, field, options) { const checkedSet = new Set(field && field.values ? field.values : []); _setTypeVal(typeEl, (field && field.values && field.values.length) ? field.type : 'none'); _populateCbList(cbListEl, options, checkedSet); } function _collectField(typeEl, cbListEl) { const type = _getTypeVal(typeEl); if (type === 'none') return null; const values = [...cbListEl.querySelectorAll('input[type="checkbox"]:checked')] .map(cb => cb.value); return values.length ? { type, values } : null; } function _populateRuleExperts(lockSelect) { const prev = ruleExpertSelect.value; ruleExpertSelect.innerHTML = '<option value="">— выберите эксперта —</option>'; (allEmployees || []) .filter(e => !e.is_fired) .sort((a, b) => a.name.localeCompare(b.name, 'ru')) .forEach(e => { const opt = document.createElement('option'); opt.value = e.name; opt.textContent = e.name; if (e.name === prev) opt.selected = true; ruleExpertSelect.appendChild(opt); }); ruleExpertSelect.disabled = lockSelect; } async function openRuleEdit(mode) { const [attrs] = await Promise.all([ _ensureDistAttrs(), // Guarantee employee list is available (dist tab may open before emp tab loads it) allEmployees.length ? Promise.resolve() : fetch('/api/employees') .then(r => r.ok ? r.json() : []) .then(data => { if (Array.isArray(data) && data.length) allEmployees = data; }) .catch(() => { }), ]); if (mode === 'add') { ruleEditTitle.textContent = 'Добавить правило'; _populateRuleExperts(false); ruleExpertSelect.value = ''; _fillField(ruleServiceType, ruleServiceValues, null, attrs.service_types); _fillField(ruleRegionType, ruleRegionValues, null, attrs.regions); _fillField(ruleRiskType, ruleRiskValues, null, attrs.risks); _fillField(rulePrefixType, rulePrefixValues, null, attrs.prefixes); } else if (typeof mode === 'object' && mode !== null) { // COPY mode: pre-fill all fields from the copied rule, including expert. // _selectedRuleId = null ensures the save handler sends POST (create), not UPDATE. const rule = mode; ruleEditTitle.textContent = `Добавить (копия: ${rule.expert_name})`; _populateRuleExperts(false); ruleExpertSelect.value = rule.expert_name; _selectedRuleId = null; _fillField(ruleServiceType, ruleServiceValues, rule.service_types, attrs.service_types); _fillField(ruleRegionType, ruleRegionValues, rule.regions, attrs.regions); _fillField(ruleRiskType, ruleRiskValues, rule.risks, attrs.risks); _fillField(rulePrefixType, rulePrefixValues, rule.prefixes, attrs.prefixes); } else { const rule = _distRulesData.find(r => r.id === _selectedRuleId); if (!rule) return; ruleEditTitle.textContent = `Изменить: ${rule.expert_name}`; _populateRuleExperts(true); ruleExpertSelect.value = rule.expert_name; // If expert isn't in the active list (fired/renamed), inject them so value survives if (ruleExpertSelect.value !== rule.expert_name) { const opt = document.createElement('option'); opt.value = rule.expert_name; opt.textContent = rule.expert_name; opt.selected = true; ruleExpertSelect.prepend(opt); } _fillField(ruleServiceType, ruleServiceValues, rule.service_types, attrs.service_types); _fillField(ruleRegionType, ruleRegionValues, rule.regions, attrs.regions); _fillField(ruleRiskType, ruleRiskValues, rule.risks, attrs.risks); _fillField(rulePrefixType, rulePrefixValues, rule.prefixes, attrs.prefixes); } modalRuleEdit.classList.remove('hidden'); } btnRuleEditSave.addEventListener('click', async () => { const expertName = ruleExpertSelect.value.trim(); if (!expertName) { ruleExpertSelect.focus(); return; } const existingRule = _distRulesData.find(r => r.id === _selectedRuleId); const body = { rule_id: _selectedRuleId, // null for new/duplicate, numeric for edit expert_name: expertName, service_types: _collectField(ruleServiceType, ruleServiceValues), regions: _collectField(ruleRegionType, ruleRegionValues), risks: _collectField(ruleRiskType, ruleRiskValues), prefixes: _collectField(rulePrefixType, rulePrefixValues), is_active: existingRule ? existingRule.is_active : true, }; btnRuleEditSave.disabled = true; const origText = btnRuleEditSave.textContent; btnRuleEditSave.textContent = 'Сохранение…'; _saveDistScroll(); try { const r = await fetch('/api/distribution/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); closeRuleEdit(); loadDistributionRules(); } catch (err) { alert(`Ошибка сохранения:\n${err.message}`); } finally { btnRuleEditSave.disabled = false; btnRuleEditSave.textContent = origText; } }); btnDistRuleAdd.addEventListener('click', () => openRuleEdit('add')); btnDistRuleEdit.addEventListener('click', () => { if (_selectedRuleId) openRuleEdit('edit'); }); btnDistRuleDelete.addEventListener('click', async () => { if (!_selectedRuleId) return; const rule = _distRulesData.find(r => r.id === _selectedRuleId); if (!rule) return; if (!confirm(`Удалить правило для «${rule.expert_name}»?`)) return; _saveDistScroll(); try { const r = await fetch(`/api/distribution/rules/${_selectedRuleId}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); _selectedRuleId = null; btnDistRuleEdit.disabled = true; btnDistRuleDelete.disabled = true; loadDistributionRules(); } catch (e) { alert(`Ошибка удаления:\n${e.message}`); } }); // ── Auto-generate rules modal ───────────────────────────────────────────────── const modalAutoGen = document.getElementById('modal-auto-gen'); const autoGenFilesEl = document.getElementById('auto-gen-files'); const autoGenThresholdEl = document.getElementById('auto-gen-threshold'); const btnStartAutoGen = document.getElementById('btn-start-auto-gen'); function openAutoGen() { modalAutoGen.classList.remove('hidden'); } function closeAutoGen() { modalAutoGen.classList.add('hidden'); } document.getElementById('btn-auto-gen').addEventListener('click', openAutoGen); document.getElementById('auto-gen-close').addEventListener('click', closeAutoGen); document.getElementById('auto-gen-cancel').addEventListener('click', closeAutoGen); modalAutoGen.addEventListener('click', e => { if (e.target === modalAutoGen) closeAutoGen(); }); btnStartAutoGen.addEventListener('click', async () => { const files = autoGenFilesEl.files; const threshold = parseFloat(autoGenThresholdEl.value) || 5.0; if (!files || files.length === 0) { alert('Выберите хотя бы один Excel-файл.'); return; } const fd = new FormData(); for (const f of files) fd.append('files', f); fd.append('threshold_pct', threshold); const origHTML = btnStartAutoGen.innerHTML; btnStartAutoGen.disabled = true; btnStartAutoGen.innerHTML = '<span class="btn-spin"></span>Анализируем…'; try { const r = await fetch('/api/distribution/auto-generate-rules', { method: 'POST', body: fd, }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); closeAutoGen(); loadDistributionRules(); const s = result.stats || {}; alert( `Правил создано: ${result.rules_created ?? '—'}.\n` + `Прочитано строк: ${s.rows_read ?? '—'}.\n` + `Найдено экспертов в Excel: ${s.unique_experts_found_in_files ?? '—'}.\n` + `Совпало с базой: ${s.experts_matched_with_db ?? '—'}.` ); } catch (err) { alert(`Ошибка авто-генерации:\n${err.message}`); } finally { btnStartAutoGen.disabled = false; btnStartAutoGen.innerHTML = origHTML; } }); // ── Distribution limits modal ───────────────────────────────────────────────── const modalDistLimits = document.getElementById('modal-dist-limits'); const distLimitsTbody = document.getElementById('dist-limits-tbody'); function closeDistLimits() { modalDistLimits.classList.add('hidden'); } document.getElementById('dist-limits-close').addEventListener('click', closeDistLimits); document.getElementById('dist-limits-cancel').addEventListener('click', closeDistLimits); modalDistLimits.addEventListener('click', e => { if (e.target === modalDistLimits) closeDistLimits(); }); function _limitsRowClass(inWork, maxLimit) { if (maxLimit == null || maxLimit <= 0) return ''; const pct = (inWork / maxLimit) * 100; if (pct >= 100) return 'limit-reached'; if (pct >= 80) return 'limit-warning'; return ''; } async function _saveLimitInline(expertName, newVal, trEl, inWork) { const maxLimit = (newVal === '' || newVal == null) ? null : parseInt(newVal, 10); if (maxLimit !== null && (isNaN(maxLimit) || maxLimit < 0)) return; try { const r = await fetch('/api/distribution/limits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expert_name: expertName, max_limit: maxLimit }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); trEl.className = _limitsRowClass(inWork, maxLimit); } catch (e) { alert(`Ошибка сохранения: ${e.message}`); } } function renderDistLimits(data) { if (!data.length) { distLimitsTbody.innerHTML = '<tr><td colspan="3" class="dist-rules-empty">Нет данных об экспертах</td></tr>'; return; } distLimitsTbody.innerHTML = data.map(row => { const cls = _limitsRowClass(row.in_work, row.max_limit); const limitVal = row.max_limit != null ? row.max_limit : ''; return `<tr class="${cls}" data-expert="${esc(row.expert_name)}" data-in-work="${row.in_work}"> <td>${esc(row.expert_name)}</td> <td style="text-align:center;font-weight:700;">${row.in_work}</td> <td style="text-align:center;"> <input type="number" class="limit-inline-input" min="0" placeholder="—" value="${limitVal}" data-expert="${esc(row.expert_name)}"> </td> </tr>`; }).join(''); distLimitsTbody.querySelectorAll('.limit-inline-input').forEach(inp => { const trEl = inp.closest('tr'); const expert = inp.dataset.expert; const inWork = parseInt(trEl.dataset.inWork, 10) || 0; inp.addEventListener('blur', () => { _saveLimitInline(expert, inp.value.trim(), trEl, inWork); }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); inp.blur(); } if (e.key === 'Escape') { inp.value = inp.defaultValue; inp.blur(); } }); }); } async function loadDistLimits() { distLimitsTbody.innerHTML = '<tr><td colspan="3" class="dist-rules-empty">Загрузка…</td></tr>'; try { const r = await fetch('/api/distribution/limits'); const data = await r.json().catch(() => []); if (!r.ok) throw new Error(data.detail || `HTTP ${r.status}`); renderDistLimits(data); } catch (e) { distLimitsTbody.innerHTML = `<tr><td colspan="3" class="dist-rules-empty" style="color:var(--color-danger);">Ошибка: ${esc(e.message)}</td></tr>`; } } btnDistLimits.addEventListener('click', () => { modalDistLimits.classList.remove('hidden'); loadDistLimits(); }); // ── Smart distribution (preview flow) ───────────────────────────────────────── async function runSmartDistribution() { btnSmartDist.disabled = true; const origHTML = btnSmartDist.innerHTML; btnSmartDist.innerHTML = 'Анализ…'; setChip(chipDlUnassigned, 'running', 'Анализ…'); try { const r = await fetch('/api/distribution/preview'); const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error((data && data.detail) || `HTTP ${r.status}`); const proposals = data.proposals || []; const underutilized = data.underutilized_experts || []; if (!proposals.length) { setChip(chipDlUnassigned, 'pending', 'Нет дел для распределения'); return; } distPreviewTbody.innerHTML = proposals.map(p => ` <tr> <td style="text-align:center;"> <input type="checkbox" checked data-case-num="${esc(p.case_number)}" data-load-date="${esc(p.load_date || '')}" data-expert="${esc(p.expert)}"> </td> <td style="font-weight:700;font-family:var(--font-mono);font-size:12px;">${esc(p.case_number)}</td> <td style="font-size:12px;">${esc(p.load_date || '—')}</td> <td style="font-size:12px;">${esc(p.region || '—')}</td> <td style="font-size:12px;">${esc(p.risk || '—')}</td> <td>${deadlineChip(p.deadline)}</td> <td style="font-weight:600;color:var(--color-primary);font-size:13px;">${esc(p.expert)}</td> <td style="text-align:center;font-size:13px;">${p.bypassed ? '<span title="Обход ограничений">⚠️</span>' : ''}</td> </tr>`).join(''); // ── Underutilized experts block ─────────────────────────────────────────── const limInfoEl = document.getElementById('distribution-limits-info'); if (limInfoEl) { if (!underutilized.length) { limInfoEl.innerHTML = '<p style="font-size:12px;color:var(--color-success);margin:0 0 10px;">' + '✓ Все задействованные эксперты достигли максимального лимита.' + '</p>'; } else { const badges = underutilized.map(e => `<span style="display:inline-block;background:var(--color-info-bg);` + `color:var(--color-info-text);border:1px solid var(--color-border-blue);` + `border-radius:var(--radius-md);padding:2px 9px;font-size:12px;` + `margin:2px 4px 2px 0;white-space:nowrap;">` + `${esc(e.name)} (ещё ${e.remaining_slots})</span>` ).join(''); limInfoEl.innerHTML = `<div style="background:var(--color-info-bg);border:1px solid var(--color-border-blue);` + `border-radius:var(--radius-md);padding:8px 12px;margin-bottom:10px;font-size:12px;">` + `<strong>Есть свободные слоты (не добрали лимит):</strong>` + `<div style="margin-top:6px;line-height:1.8;">${badges}</div>` + `</div>`; } // ── Unmatched cases block (service types not in any active rule) ────────── const unmatched = data.unmatched_cases || []; const unmatchedEl = document.getElementById('preview-unmatched-cases'); if (unmatchedEl) { if (unmatched.length) { // Group by service type const bySvcType = {}; unmatched.forEach(u => { const svcList = (u.service_types && u.service_types.length) ? u.service_types.join(', ') : '—'; if (!bySvcType[svcList]) bySvcType[svcList] = []; bySvcType[svcList].push(u); }); const svcTypeKeys = Object.keys(bySvcType).sort(); const itemsHtml = svcTypeKeys.map(svcKey => { const cases = bySvcType[svcKey]; const caseNums = cases.map(c => esc(c.case_number)).join(', '); return `<div style="margin-bottom:6px;font-size:12px;line-height:1.6;"> <strong>${esc(svcKey)} (${cases.length})</strong> — ${caseNums} </div>`; }).join(''); unmatchedEl.style.display = 'block'; unmatchedEl.innerHTML = '<h6 class="text-danger mt-3" style="margin:14px 0 6px;font-size:13px;color:var(--color-danger);">' + 'Не распределены (отсутствуют в настройках типов услуг):</h6>' + '<div style="background:var(--color-danger-bg, #fff0f0);' + 'border:1px solid var(--color-border-red, #e0b0b0);' + 'border-radius:var(--radius-md);padding:8px 12px;margin-bottom:6px;">' + itemsHtml + '</div>'; } else { unmatchedEl.style.display = 'none'; unmatchedEl.innerHTML = ''; } } } distPreviewAllCb.checked = true; distPreviewHint.textContent = `Найдено ${proposals.length} назначений. Снимите галочки с ненужных и нажмите «Применить».`; btnDistPreviewApply.disabled = false; btnDistPreviewApply.textContent = `✔ Применить (${proposals.length})`; clearChip(chipDlUnassigned); modalDistPreview.classList.remove('hidden'); } catch (err) { setChip(chipDlUnassigned, 'error', 'Ошибка'); alert(`Ошибка предпросмотра:\n${err.message}`); } finally { btnSmartDist.disabled = false; btnSmartDist.innerHTML = origHTML; } } btnSmartDist.addEventListener('click', runSmartDistribution); // ── Send assignments modal ───────────────────────────────────────────────────── const modalSendAssignments = document.getElementById('modal-send-assignments'); const sendAssignList = document.getElementById('send-assign-list'); const sendAssignSubmit = document.getElementById('send-assign-submit'); const tplAssignSubjectEl = document.getElementById('tpl-assign-subject'); const tplAssignBodyEl = document.getElementById('tpl-assign-body'); const tplAssignPreviewEl = document.getElementById('tpl-assign-preview'); const ASSIGN_DEFAULT_SUBJECT = 'Новые назначения на расчет'; const ASSIGN_DEFAULT_BODY = 'Здравствуйте!\n\n' + 'Вам назначены новые дела:\n\n' + '{СПИСОК_ДЕЛ}\n\n' + 'Просьба принять в работу.'; function _updateAssignPreview() { const subj = tplAssignSubjectEl.value; const body = tplAssignBodyEl.value; // Use first expert's data for preview substitution const candidates = _getAssignmentCandidates(); let previewExpert = ''; let previewList = ' • (примеры дел)'; if (candidates.length) { previewExpert = candidates[0].candidate_expert || ''; const expertCases = candidates.filter(c => c.candidate_expert === previewExpert); previewList = expertCases .map(c => ` • ${c.case_number} (Срок: ${c.deadline ?? 0} дн.)`) .join('\n'); } const rendered = body .replace(/\{ФИО\}/g, previewExpert) .replace(/\{СПИСОК_ДЕЛ\}/g, previewList); const renderedSubj = subj.replace(/\{ФИО\}/g, previewExpert); tplAssignPreviewEl.innerHTML = `<div class="tpl-preview-subject">Тема: ${esc(renderedSubj)}</div>` + esc(rendered).replace(/\n/g, '<br>'); } [tplAssignSubjectEl, tplAssignBodyEl].forEach(el => el.addEventListener('input', _updateAssignPreview) ); function closeSendAssignModal() { modalSendAssignments.classList.add('hidden'); } document.getElementById('send-assign-close').addEventListener('click', closeSendAssignModal); document.getElementById('send-assign-cancel').addEventListener('click', closeSendAssignModal); modalSendAssignments.addEventListener('click', e => { if (e.target === modalSendAssignments) closeSendAssignModal(); }); // Collect cases with candidates from allDeadlines function _getAssignmentCandidates() { const threshold = parseInt(dlThreshold.value, 10); return allDeadlines.filter( c => isUnassigned(c) && !isSysCase(c.case_number) && (c.deadline || 0) >= threshold && c.candidate_expert ); } btnSendAssignments.addEventListener('click', async () => { const candidates = _getAssignmentCandidates(); if (!candidates.length) { alert('Нет кандидатов для отправки. Запустите умное распределение.'); return; } // Group by expert for candidate list preview const byExpert = {}; candidates.forEach(c => { if (!byExpert[c.candidate_expert]) byExpert[c.candidate_expert] = []; byExpert[c.candidate_expert].push(c.case_number); }); sendAssignList.innerHTML = Object.entries(byExpert).map(([expert, cases]) => `<div style="margin-bottom:10px;"> <div style="font-weight:700;color:var(--color-primary);margin-bottom:3px;"> ${esc(expert)} </div> <ul style="margin:0;padding-left:18px;color:var(--color-text-muted);"> ${cases.map(cn => `<li style="font-family:var(--font-mono);font-size:12px;">${esc(cn)}</li>`).join('')} </ul> </div>` ).join('<hr style="border:none;border-top:1px solid var(--color-border);margin:6px 0;">'); // Load template from DB const tpl = await loadTemplate('assignments'); tplAssignSubjectEl.value = (tpl && tpl.subject) || ASSIGN_DEFAULT_SUBJECT; tplAssignBodyEl.value = (tpl && tpl.body) || ASSIGN_DEFAULT_BODY; _updateAssignPreview(); modalSendAssignments.classList.remove('hidden'); }); sendAssignSubmit.addEventListener('click', async () => { const candidates = _getAssignmentCandidates(); if (!candidates.length) { closeSendAssignModal(); return; } const assignments = candidates.map(c => ({ case_number: c.case_number, expert_name: c.candidate_expert, deadline: c.deadline ?? 0, })); sendAssignSubmit.disabled = true; const origText = sendAssignSubmit.textContent; sendAssignSubmit.textContent = 'Отправка…'; try { const r = await fetch('/api/emails/send-assignments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assignments, template_subject: tplAssignSubjectEl.value, template_body: tplAssignBodyEl.value, }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); await saveTemplate('assignments', tplAssignSubjectEl.value, tplAssignBodyEl.value); closeSendAssignModal(); setChip(chipDlUnassigned, 'done', `Назначения отправлены: ${result.sent} экспертам`); setTimeout(() => clearChip(chipDlUnassigned), 5000); } catch (err) { alert(`Ошибка отправки:\n${err.message}`); } finally { sendAssignSubmit.disabled = false; sendAssignSubmit.textContent = origText; } }); // ── "Рассылка операторам" shared helpers ────────────────────────────────────── // Builds a clean HTML email body grouped by expert as a simple bullet list. // cases: [{case_number, expert_name|candidate_expert}] function _buildOperatorEmailHtml(cases) { const byExpert = {}; cases.forEach(c => { const exp = (c.expert_name || c.candidate_expert || '—').trim() || '—'; if (!byExpert[exp]) byExpert[exp] = []; byExpert[exp].push(c.case_number || ''); }); let html = '<p>Направляю сводный реестр дел для назначения по экспертам:</p><ul>'; for (const expertName of Object.keys(byExpert).sort()) { const cases = byExpert[expertName]; const caseNums = cases.join(', '); html += `<li><strong>${esc(expertName)} (${cases.length})</strong> — ${esc(caseNums)}</li>`; } html += '</ul>'; return html; } let _notifyOpsGroup = 'assignments'; // tracks which settings key to use on send async function _openOperatorMailingModal(cases, group) { _notifyOpsGroup = group; const emailsEndpoint = group === 'all_tasks' ? '/api/settings/all-tasks-emails' : '/api/settings/assignments-emails'; // Build WYSIWYG preview (full HTML rendered inside contenteditable div) const htmlBody = _buildOperatorEmailHtml(cases); operatorMailingBody.innerHTML = htmlBody; // Default subject const today = new Date().toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); operatorMailingSubject.value = `Реестр дел для назначения на ${today}`; // Load recipient addresses try { const r = await fetch(emailsEndpoint); if (r.ok) { const data = await r.json(); const addrs = (data.to || '').split(/[,;]+/).map(s => s.trim()).filter(Boolean); const ccAddr = (data.cc || '').split(/[,;]+/).map(s => s.trim()).filter(Boolean); const parts = []; if (addrs.length) parts.push(`Кому: ${addrs.join(', ')}`); if (ccAddr.length) parts.push(`Копия: ${ccAddr.join(', ')}`); operatorMailingTo.value = parts.join(' | ') || 'Адреса не настроены'; } else { operatorMailingTo.value = 'Не удалось загрузить адреса'; } } catch (_) { operatorMailingTo.value = 'Ошибка загрузки адресов'; } operatorMailingModal.classList.remove('hidden'); } // ── "Рассылка операторам" — modal refs ──────────────────────────────────────── const operatorMailingModal = document.getElementById('operator-mailing-modal'); const operatorMailingTo = document.getElementById('operator-mailing-to'); const operatorMailingSubject = document.getElementById('operator-mailing-subject'); const operatorMailingBody = document.getElementById('operator-mailing-body'); const confirmOperatorMailing = document.getElementById('confirm-operator-mailing'); btnNotifyOperatorsAssign.addEventListener('click', async () => { const candidates = _getAssignmentCandidates(); if (!candidates.length) { alert('Нет кандидатов для рассылки. Запустите умное распределение.'); return; } const cases = candidates.map(c => ({ case_number: c.case_number, expert_name: c.candidate_expert, deadline: c.deadline ?? 0, region: c.region || '', department: c.department || '', })); await _openOperatorMailingModal(cases, 'assignments'); }); // ── Confirm operator mailing ────────────────────────────────────────────────── confirmOperatorMailing.addEventListener('click', async () => { const htmlBody = operatorMailingBody.innerHTML; if (!htmlBody || !htmlBody.trim()) { alert('Тело письма пустое.'); return; } confirmOperatorMailing.disabled = true; const origText = confirmOperatorMailing.textContent; confirmOperatorMailing.textContent = 'Отправка…'; try { const r = await fetch('/api/emails/notify-operators', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ html_body: htmlBody, group: _notifyOpsGroup, subject: operatorMailingSubject.value, }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); operatorMailingModal.classList.add('hidden'); const chip = _notifyOpsGroup === 'all_tasks' ? chipDlAll : chipDlUnassigned; setChip(chip, 'done', `Рассылка операторам: отправлено на ${result.sent} адр.`); setTimeout(() => clearChip(chip), 6000); } catch (err) { alert(`Ошибка рассылки операторам:\n${err.message}`); } finally { confirmOperatorMailing.disabled = false; confirmOperatorMailing.textContent = origText; } }); // ── Close modal handlers ────────────────────────────────────────────────────── function closeOperatorMailingModal() { operatorMailingModal.classList.add('hidden'); } document.getElementById('operator-mailing-close').addEventListener('click', closeOperatorMailingModal); document.getElementById('operator-mailing-cancel').addEventListener('click', closeOperatorMailingModal); operatorMailingModal.addEventListener('click', e => { if (e.target === operatorMailingModal) closeOperatorMailingModal(); }); btnAddDl.addEventListener('click', openDeadlineModal); document.getElementById('modal-close-btn').addEventListener('click', closeDeadlineModal); document.getElementById('modal-cancel-btn').addEventListener('click', closeDeadlineModal); dlModal.addEventListener('click', e => { if (e.target === dlModal) closeDeadlineModal(); }); dlModalForm.addEventListener('submit', async e => { e.preventDefault(); const file = fileInput.files[0]; if (!file) return; dlSubmitBtn.disabled = true; dlSubmitBtn.textContent = 'Обработка…'; const fd = new FormData(); fd.append('file', file); try { const r = await fetch('/api/upload-excel', { method: 'POST', body: fd }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); closeDeadlineModal(); const warn = result.total_errors ? ` (${result.total_errors} ошибок разбора)` : ''; setChip(dlChip, result.total_errors ? 'pending' : 'done', `Загружено: ${result.saved}${warn}`); tabLoaded.deadlines = false; loadDeadlines(); } catch (err) { alert(`Ошибка загрузки:\n${err.message}`); dlSubmitBtn.disabled = false; dlSubmitBtn.textContent = 'Загрузить'; } }); // ── Delete historical upload ────────────────────────────────────────────────── async function _fetchUploadDates() { const sel = document.getElementById('dl-upload-date-sel'); const btn = document.getElementById('btn-delete-upload'); if (!sel) return; try { const r = await fetch('/api/deadlines/dates'); if (!r.ok) return; const dates = await r.json(); sel.innerHTML = '<option value="">— история загрузок —</option>' + dates.map(d => `<option value="${esc(d)}">${esc(d)}</option>`).join(''); if (btn) btn.disabled = !dates.length; } catch (_) { } } document.getElementById('dl-upload-date-sel')?.addEventListener('change', () => { const sel = document.getElementById('dl-upload-date-sel'); const btn = document.getElementById('btn-delete-upload'); if (btn) btn.disabled = !sel?.value; }); document.getElementById('btn-delete-upload')?.addEventListener('click', async () => { const sel = document.getElementById('dl-upload-date-sel'); const date = sel?.value; if (!date) { alert('Выберите дату из списка'); return; } if (!confirm(`Вы уверены, что хотите полностью удалить данные за ${date}?`)) return; const btn = document.getElementById('btn-delete-upload'); btn.disabled = true; try { const r = await fetch(`/api/deadlines/upload/${encodeURIComponent(date)}`, { method: 'DELETE' }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); setChip(dlChip, 'done', `Удалено: ${result.deleted} записей`); tabLoaded.deadlines = false; loadDeadlines(); } catch (err) { alert(`Ошибка удаления:\n${err.message}`); btn.disabled = false; } }); // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // VACATIONS (timeline grid view) // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const vacContainer = document.getElementById('vacations-container'); const vacFilter = document.getElementById('filter-vac'); const vacDeptSelect = document.getElementById('filter-vac-dept'); const vacYearSelect = document.getElementById('filter-vac-year'); const vacMonthSelect = document.getElementById('filter-vac-month'); const btnVacPrev = document.getElementById('btn-vac-prev'); const btnVacNext = document.getElementById('btn-vac-next'); const btnAddVac = document.getElementById('btn-add-vac'); const btnEditVac = document.getElementById('btn-edit-vac'); const btnDeleteVac = document.getElementById('btn-delete-vac'); let allVacations = []; let selectedVacId = null; let vacViewMode = 'month'; // 'month' | 'year' let _vacEmpList = []; // all active non-temp employees for Y-axis let _vacCalDays = new Map(); // iso → {is_weekend, is_holiday, is_today} const MONTH_NAMES = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь', ]; const MONTH_GEN = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря', ]; const DOW_SHORT = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; // Populate year + month selects at script load time (function initVacPeriod() { const now = new Date(); const curY = now.getFullYear(); const curM = now.getMonth(); for (let y = curY - 2; y <= curY + 2; y++) { const o = document.createElement('option'); o.value = y; o.textContent = y; if (y === curY) o.selected = true; vacYearSelect.appendChild(o); } MONTH_NAMES.forEach((name, i) => { const o = document.createElement('option'); o.value = i; o.textContent = name; if (i === curM) o.selected = true; vacMonthSelect.appendChild(o); }); })(); async function loadVacDepartments() { try { const r = await fetch('/api/employees/departments'); if (!r.ok) return; const depts = await r.json(); depts.forEach(d => { const o = document.createElement('option'); o.value = d; o.textContent = d; vacDeptSelect.appendChild(o); }); } catch (_) { } } loadVacDepartments(); function setSelectedVac(id) { selectedVacId = id; btnEditVac.disabled = id === null; btnDeleteVac.disabled = id === null; vacContainer.querySelectorAll('[data-vac-id]').forEach(el => { el.style.outline = String(el.dataset.vacId) === String(id) ? '2px inset rgba(0,0,0,.45)' : ''; }); } /** Format YYYY-MM-DD → DD.MM.YYYY */ function fmtDate(s) { if (!s) return '—'; const [y, m, d] = s.split('-'); return `${d}.${m}.${y}`; } function pad2(n) { return String(n).padStart(2, '0'); } function toISO(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** Shorten "Фамилия Имя Отчество" → "Фамилия И.О." */ function shortName(fullName) { const parts = (fullName || '').trim().split(/\s+/); if (parts.length <= 1) return parts[0] || ''; const [last, ...rest] = parts; return `${last} ${rest.filter(Boolean).map(p => p[0].toUpperCase() + '.').join('')}`; } /** Pick vacation cell CSS classes based on type + conflict flags. * Always returns the base type class; appends overlap-warning when conflicting. */ function vacCellClass(v) { const ov = v.overlap_names && v.overlap_names.length > 0; const base = v.is_working ? 'vac-working' : (v.v_type === 'Зимний' ? 'vac-winter' : 'vac-summer'); return ov ? `${base} overlap-warning` : base; } // ── Vacation render dispatcher ───────────────────────────────────────────────── function renderVacations() { if (vacViewMode === 'year') { _renderVacationsAnnual(); } else { _renderVacationsMonth(); } } // ── Month view ───────────────────────────────────────────────────────────────── function _renderVacationsMonth() { const year = parseInt(vacYearSelect.value) || new Date().getFullYear(); const month = parseInt(vacMonthSelect.value); const q = vacFilter.value.trim().toLowerCase(); // Build day array for the selected month const days = []; for (let d = new Date(year, month, 1); d.getMonth() === month; d.setDate(d.getDate() + 1)) days.push(new Date(d)); const mStart = toISO(days[0]); const mEnd = toISO(days[days.length - 1]); const todayISO = toISO(new Date()); const filterActive = document.getElementById('filter-active-vacations')?.checked; // Y-axis: ALL active non-temp employees, filtered by search query + active-only toggle const emps = _vacEmpList.filter(e => { const matchesSearch = !q || (e.name || '').toLowerCase().includes(q) || (e.department || '').toLowerCase().includes(q); if (!matchesSearch) return false; if (filterActive) { return allVacations.some(v => v.employee_id === e.id && v.start_date <= mEnd && v.end_date >= mStart ); } return true; }); if (!emps.length) { vacContainer.innerHTML = emptyMsg('Нет сотрудников для отображения.'); updateVacationSidebar(); return; } // Build vacation lookup: employee_id → vacations overlapping this month const vacByEmp = new Map(); allVacations.forEach(v => { if (v.start_date > mEnd || v.end_date < mStart) return; if (!vacByEmp.has(v.employee_id)) vacByEmp.set(v.employee_id, []); vacByEmp.get(v.employee_id).push(v); }); // Header: two-row (date number + weekday name), red for weekends const headCells = days.map(d => { const iso = toISO(d); const isWE = _vacCalDays.get(iso)?.is_weekend ?? (d.getDay() === 0 || d.getDay() === 6); const isTd = iso === todayISO; const cls = [isWE ? 'vac-hdr-weekend' : '', isTd ? 'day-today' : ''].filter(Boolean).join(' '); return `<th${cls ? ` class="${cls}"` : ''}>${d.getDate()}<br><small>${DOW_SHORT[d.getDay()]}</small></th>`; }).join(''); // Body rows — one per employee regardless of whether they have vacations const bodyRows = emps.map(emp => { const empId = emp.id; const vacations = vacByEmp.get(empId) || []; const deptSpan = emp.department ? ` <span class="dept-badge">${esc(emp.department)}</span>` : ''; const cells = days.map(d => { const iso = toISO(d); const isWE = _vacCalDays.get(iso)?.is_weekend ?? (d.getDay() === 0 || d.getDay() === 6); const isTd = iso === todayISO; const dayCls = isWE ? 'cal-day-off' : 'cal-day-work'; const vac = vacations.find(v => v.start_date <= iso && v.end_date >= iso); const base = `data-emp-id="${empId}" data-date="${iso}"`; if (vac) { const cls = `${vacCellClass(vac)} ${dayCls}`; const extra = isTd ? ' day-today' : ''; const lines = [`${vac.v_type || 'Отпуск'}: ${fmtDate(vac.start_date)} — ${fmtDate(vac.end_date)} (${vac.days ?? '?'} дн.)`]; if (vac.is_working) lines.push('★ Работает (производственная необходимость)'); if (vac.overlap_names?.length) lines.push('⚠ ' + vac.overlap_names.join(', ')); return `<td class="${cls}${extra}" ${base} data-vac-id="${vac.id}" title="${esc(lines.join('\n'))}"></td>`; } if (isTd) return `<td class="${dayCls} day-today" ${base}></td>`; return `<td class="${dayCls}" ${base}></td>`; }).join(''); return `<tr> <td class="timeline-name-col" title="${esc(emp.name)} (${esc(emp.department || '—')})"><div class="fixed-name">${esc(formatFioToInitials(emp.name))}${deptSpan}</div></td> ${cells} </tr>`; }).join(''); const dayCols = days.map(() => '<col>').join(''); const vacPeriods = [...vacByEmp.values()].reduce((s, a) => s + a.length, 0); vacContainer.innerHTML = ` <table class="timeline-table"> <colgroup><col style="width:150px">${dayCols}</colgroup> <thead><tr> <th class="timeline-name-col"><div class="fixed-name">Сотрудник</div></th> ${headCells} </tr></thead> <tbody>${bodyRows}</tbody> </table> <p style="margin-top:8px;padding:0 6px;font-size:12px;color:var(--color-text-muted);"> Показано: <strong>${emps.length}</strong> сотрудников · <strong>${vacPeriods}</strong> периодов </p>`; // Click → select vacation; double-click → open edit modal vacContainer.querySelectorAll('td[data-vac-id]').forEach(td => { td.addEventListener('click', () => { const id = parseInt(td.dataset.vacId, 10); setSelectedVac(selectedVacId === id ? null : id); }); td.addEventListener('dblclick', () => { const id = parseInt(td.dataset.vacId, 10); setSelectedVac(id); openVacModal(id); }); }); updateVacationSidebar(); } // ── Annual view ──────────────────────────────────────────────────────────────── function _renderVacationsAnnual() { const year = parseInt(vacYearSelect.value) || new Date().getFullYear(); const q = vacFilter.value.trim().toLowerCase(); const yearStart = `${year}-01-01`; const yearEnd = `${year}-12-31`; const todayM = new Date().getMonth(); const todayY = new Date().getFullYear(); const filterActive = document.getElementById('filter-active-vacations')?.checked; // Y-axis: ALL active non-temp employees, filtered by search query + active-only toggle const emps = _vacEmpList.filter(e => { const matchesSearch = !q || (e.name || '').toLowerCase().includes(q) || (e.department || '').toLowerCase().includes(q); if (!matchesSearch) return false; if (filterActive) { return allVacations.some(v => v.employee_id === e.id && v.start_date <= yearEnd && v.end_date >= yearStart ); } return true; }); if (!emps.length) { vacContainer.innerHTML = emptyMsg('Нет сотрудников для отображения.'); updateVacationSidebar(); return; } // Build vacation lookup: employee_id → vacations[] overlapping this year const vacByEmp = new Map(); allVacations.forEach(v => { if (v.start_date > yearEnd || v.end_date < yearStart) return; if (!vacByEmp.has(v.employee_id)) vacByEmp.set(v.employee_id, []); vacByEmp.get(v.employee_id).push(v); }); // % position on the 12-equal-column scale; within each month column days are proportional function _annualPct(isoDate) { const d = new Date(isoDate + 'T00:00:00'); if (d.getFullYear() < year) return 0; if (d.getFullYear() > year) return 100; const m = d.getMonth(); const daysInM = new Date(year, m + 1, 0).getDate(); return ((m + (d.getDate() - 1) / daysInM) / 12) * 100; } // Header: 12 month columns, highlight current month const headCells = MONTH_NAMES.map((name, i) => { const isCur = todayY === year && todayM === i; return `<th class="vac-annual-th${isCur ? ' day-today' : ''}">${name}</th>`; }).join(''); const monthColgroup = Array(12).fill('<col style="width:8.333%">').join(''); // Body rows — one per employee regardless of vacation presence const bodyRows = emps.map(emp => { const empId = emp.id; const vacations = vacByEmp.get(empId) || []; const deptSpan = emp.department ? ` <span class="dept-badge">${esc(emp.department)}</span>` : ''; const bars = vacations.map(v => { const clampStart = v.start_date < yearStart ? yearStart : v.start_date; const clampEnd = v.end_date > yearEnd ? yearEnd : v.end_date; const left = _annualPct(clampStart); const endDt = new Date(clampEnd + 'T00:00:00'); endDt.setDate(endDt.getDate() + 1); const right = endDt.getFullYear() > year ? 100 : _annualPct(toISO(endDt)); const width = Math.max(right - left, 0.4); const cls = vacCellClass(v); const lines = [`${v.v_type || 'Отпуск'}: ${fmtDate(v.start_date)} — ${fmtDate(v.end_date)} (${v.days ?? '?'} дн.)`]; if (v.is_working) lines.push('★ Работает (производственная необходимость)'); if (v.overlap_names?.length) lines.push('⚠ ' + v.overlap_names.join(', ')); return `<div class="vac-annual-bar ${cls}" style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%" data-vac-id="${v.id}" title="${esc(lines.join('\n'))}"></div>`; }).join(''); return `<tr> <td class="timeline-name-col" title="${esc(emp.name)} (${esc(emp.department || '—')})"> <div class="fixed-name">${esc(formatFioToInitials(emp.name))}${deptSpan}</div> </td> <td colspan="12" class="vac-annual-row">${bars}</td> </tr>`; }).join(''); const vacPeriods = [...vacByEmp.values()].reduce((s, a) => s + a.length, 0); vacContainer.innerHTML = ` <table class="timeline-table vac-annual-table"> <colgroup><col>${monthColgroup}</colgroup> <thead><tr> <th class="timeline-name-col"><div class="fixed-name">Сотрудник</div></th> ${headCells} </tr></thead> <tbody>${bodyRows}</tbody> </table> <p style="margin-top:8px;padding:0 6px;font-size:12px;color:var(--color-text-muted);"> <strong>${year}</strong> год · <strong>${emps.length}</strong> сотрудников · <strong>${vacPeriods}</strong> периодов </p>`; // Click/dblclick on bars vacContainer.querySelectorAll('.vac-annual-bar[data-vac-id]').forEach(bar => { bar.addEventListener('click', e => { e.stopPropagation(); const id = parseInt(bar.dataset.vacId, 10); setSelectedVac(selectedVacId === id ? null : id); }); bar.addEventListener('dblclick', e => { e.stopPropagation(); const id = parseInt(bar.dataset.vacId, 10); setSelectedVac(id); openVacModal(id); }); }); updateVacationSidebar(); } // ── Vacation sidebar: dynamic calculations ──────────────────────────────────── function updateVacationSidebar() { const elOverlaps = document.getElementById('vac-sidebar-overlaps'); const elActive = document.getElementById('vac-sidebar-active'); const elUpcoming = document.getElementById('vac-sidebar-upcoming'); if (!elOverlaps || !elActive || !elUpcoming) return; const todayISO = toISO(new Date()); const deptFilter = vacDeptSelect.value; // ── Widget 1: same-dept overlaps within the viewed month ───────────────── const year = parseInt(vacYearSelect.value) || new Date().getFullYear(); const month = parseInt(vacMonthSelect.value); const days = []; for (let d = new Date(year, month, 1); d.getMonth() === month; d.setDate(d.getDate() + 1)) days.push(new Date(d)); const mStart = toISO(days[0]); const mEnd = toISO(days[days.length - 1]); const monthVacs = allVacations.filter(v => { if (v.start_date > mEnd || v.end_date < mStart) return false; if (deptFilter && v.department !== deptFilter) return false; return true; }); const byDept = new Map(); monthVacs.forEach(v => { const dept = v.department || '(без отдела)'; if (!byDept.has(dept)) byDept.set(dept, []); byDept.get(dept).push(v); }); const overlapLines = []; byDept.forEach((vacList, dept) => { if (vacList.length < 2) return; const seen = new Set(); for (let i = 0; i < vacList.length; i++) { for (let j = i + 1; j < vacList.length; j++) { const a = vacList[i], b = vacList[j]; if (a.employee_id === b.employee_id) continue; // Clamp to month window, then test overlap const aS = a.start_date < mStart ? mStart : a.start_date; const aE = a.end_date > mEnd ? mEnd : a.end_date; const bS = b.start_date < mStart ? mStart : b.start_date; const bE = b.end_date > mEnd ? mEnd : b.end_date; if (aS <= bE && bS <= aE) { const key = [shortName(a.emp_name), shortName(b.emp_name)].sort().join(' / '); if (!seen.has(key)) { seen.add(key); overlapLines.push( `<div class="vac-sb-row"><span class="vac-sb-dept">${esc(dept)}:</span> ${esc(key)}</div>` ); } } } } }); elOverlaps.innerHTML = overlapLines.length ? overlapLines.join('') : '<em>Нет пересечений</em>'; // ── Widget 2: on vacation right now ────────────────────────────────────── const todayMap = new Map(); allVacations.forEach(v => { if (deptFilter && v.department !== deptFilter) return; if (v.start_date <= todayISO && v.end_date >= todayISO) todayMap.set(v.employee_id, v.emp_name); }); const todayNames = [...todayMap.values()].map(n => shortName(n)); elActive.innerHTML = todayNames.length ? `<strong>${todayNames.length}</strong> сотрудн. (${todayNames.map(esc).join(', ')})` : '<em>Все на рабочих местах</em>'; // ── Widget 3: starting vacation in the next 7 calendar days ────────────── const t1 = new Date(); t1.setDate(t1.getDate() + 1); const t7 = new Date(); t7.setDate(t7.getDate() + 7); const d1ISO = toISO(t1); const d7ISO = toISO(t7); const upcomingMap = new Map(); allVacations.forEach(v => { if (deptFilter && v.department !== deptFilter) return; if (v.start_date >= d1ISO && v.start_date <= d7ISO) { const prev = upcomingMap.get(v.employee_id); if (!prev || v.start_date < prev.start_date) upcomingMap.set(v.employee_id, v); } }); if (upcomingMap.size === 0) { elUpcoming.innerHTML = '<em>Нет ближайших отпусков</em>'; } else { const list = [...upcomingMap.values()].sort((a, b) => a.start_date.localeCompare(b.start_date)); elUpcoming.innerHTML = list.map(v => { const d = new Date(v.start_date + 'T00:00:00'); const lbl = `${d.getDate()} ${MONTH_GEN[d.getMonth()]}`; return `<div>${esc(shortName(v.emp_name || ''))} <span class="vac-sb-date">(с ${lbl})</span></div>`; }).join(''); } } async function loadVacations() { vacContainer.innerHTML = ''; setSelectedVac(null); _vacEmpList = []; _vacCalDays = new Map(); try { const dept = vacDeptSelect.value; const year = vacYearSelect.value || new Date().getFullYear(); const vacParams = new URLSearchParams(); if (dept) vacParams.set('department', dept); if (vacYearSelect.value) vacParams.set('year', vacYearSelect.value); const empParams = new URLSearchParams(); if (dept) empParams.set('department', dept); const calParams = new URLSearchParams({ year }); const [vacRes, empRes, calRes] = await Promise.all([ fetch('/api/vacations?' + vacParams), fetch('/api/employees?' + empParams), fetch('/api/calendar/days?' + calParams), ]); if (!vacRes.ok) throw new Error(`HTTP ${vacRes.status}`); allVacations = await vacRes.json(); if (empRes.ok) { const rawEmps = await empRes.json(); _vacEmpList = rawEmps.filter(e => !e.is_temp && !e.is_fired); } if (calRes.ok) { const calData = await calRes.json(); (calData.days || []).forEach(d => _vacCalDays.set(d.iso, d)); } tabLoaded.vacations = true; renderVacations(); } catch (e) { vacContainer.innerHTML = errBlock(e.message); } } vacFilter.addEventListener('input', renderVacations); vacDeptSelect.addEventListener('change', () => { tabLoaded.vacations = false; loadVacations(); }); vacYearSelect.addEventListener('change', () => { tabLoaded.vacations = false; loadVacations(); }); vacMonthSelect.addEventListener('change', renderVacations); // month change re-slices local data document.getElementById('filter-active-vacations').addEventListener('change', renderVacations); // ── View-mode toggle ────────────────────────────────────────────────────────── function _setVacViewMode(mode) { vacViewMode = mode; document.querySelectorAll('.vac-view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === mode); }); // In year mode the month select is irrelevant — hide it vacMonthSelect.style.display = mode === 'year' ? 'none' : ''; renderVacations(); } document.getElementById('btn-vac-view-month').addEventListener('click', () => _setVacViewMode('month')); document.getElementById('btn-vac-view-year').addEventListener('click', () => _setVacViewMode('year')); btnVacPrev.addEventListener('click', () => { const year = parseInt(vacYearSelect.value); if (vacViewMode === 'year') { vacYearSelect.value = year - 1; tabLoaded.vacations = false; loadVacations(); return; } const month = parseInt(vacMonthSelect.value); if (month === 0) { vacYearSelect.value = year - 1; vacMonthSelect.value = 11; tabLoaded.vacations = false; loadVacations(); } else { vacMonthSelect.value = month - 1; renderVacations(); } }); btnVacNext.addEventListener('click', () => { const year = parseInt(vacYearSelect.value); if (vacViewMode === 'year') { vacYearSelect.value = year + 1; tabLoaded.vacations = false; loadVacations(); return; } const month = parseInt(vacMonthSelect.value); if (month === 11) { vacYearSelect.value = year + 1; vacMonthSelect.value = 0; tabLoaded.vacations = false; loadVacations(); } else { vacMonthSelect.value = month + 1; renderVacations(); } }); // ── Vacation brush (drag-to-select) ────────────────────────────────────────── const _vacBrushMenu = document.getElementById('vac-brush-menu'); let _vacBrushing = false; let _vacBrushEmpId = null; // string from dataset const _vacBrushCells = new Map(); // iso_date → td element function _vacBrushAddCell(td) { const date = td.dataset.date; if (date && !_vacBrushCells.has(date)) { _vacBrushCells.set(date, td); td.classList.add('cell-selected'); } } function _vacBrushClose() { _vacBrushMenu.classList.add('hidden'); _vacBrushCells.forEach(td => td.classList.remove('cell-selected')); _vacBrushCells.clear(); _vacBrushEmpId = null; _vacBrushing = false; } function _vacBrushShowMenu(x, y) { _vacBrushMenu.style.left = Math.min(x + 6, window.innerWidth - 240) + 'px'; _vacBrushMenu.style.top = Math.min(y + 6, window.innerHeight - 120) + 'px'; _vacBrushMenu.classList.remove('hidden'); } // mousedown starts brush on a day cell vacContainer.addEventListener('mousedown', e => { const td = e.target.closest('td[data-date][data-emp-id]'); if (!td) return; e.preventDefault(); // prevent text selection during drag _vacBrushClose(); _vacBrushing = true; _vacBrushEmpId = td.dataset.empId; _vacBrushAddCell(td); }); // mousemove extends selection within the same employee row vacContainer.addEventListener('mousemove', e => { if (!_vacBrushing) return; const td = e.target.closest('td[data-date][data-emp-id]'); if (!td || td.dataset.empId !== _vacBrushEmpId) return; _vacBrushAddCell(td); }); // mouseup on document shows the popup document.addEventListener('mouseup', e => { if (!_vacBrushing) return; _vacBrushing = false; if (_vacBrushCells.size > 0) { _vacBrushShowMenu(e.clientX, e.clientY); } else { _vacBrushClose(); } }); // Escape cancels document.addEventListener('keydown', e => { if (e.key === 'Escape' && !_vacBrushMenu.classList.contains('hidden')) { _vacBrushClose(); } }); // Brush menu buttons let _vacBrushPending = false; _vacBrushMenu.querySelectorAll('[data-vac-action]').forEach(btn => { btn.addEventListener('click', async () => { const action = btn.dataset.vacAction; if (action === 'cancel') { _vacBrushClose(); return; } const dates = Array.from(_vacBrushCells.keys()).sort(); const empId = parseInt(_vacBrushEmpId, 10); _vacBrushClose(); if (!dates.length || isNaN(empId) || _vacBrushPending) return; _vacBrushPending = true; const payload = { employee_id: empId, start_date: dates[0], end_date: dates[dates.length - 1], v_type: action === 'summer' ? 'Летний' : action === 'winter' ? 'Зимний' : null, }; try { const r = await fetch('/api/vacations/brush-set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); tabLoaded.vacations = false; await loadVacations(); } catch (err) { alert(`Ошибка записи отпуска:\n${err.message}`); } finally { _vacBrushPending = false; } }); }); // ── Vacation CRUD modal ─────────────────────────────────────────────────────── const vacModal = document.getElementById('modal-vacation'); const vacModalForm = document.getElementById('form-vacation'); const vacModalTitle = document.getElementById('vac-modal-title'); const vacModalSubmit = document.getElementById('vac-modal-submit'); const vacStartInput = document.getElementById('vac-start'); const vacEndInput = document.getElementById('vac-end'); const vacWeeksInput = document.getElementById('vac-weeks'); let editingVacId = null; /** Recalculate end date from start + weeks. */ function calcVacEnd() { const weeks = parseInt(vacWeeksInput.value, 10); if (!isNaN(weeks) && weeks > 0 && vacStartInput.value) { const d = new Date(vacStartInput.value); d.setDate(d.getDate() + weeks * 7 - 1); vacEndInput.value = d.toISOString().split('T')[0]; } } /** Recalculate weeks from start + end. */ function calcVacWeeks() { if (vacStartInput.value && vacEndInput.value) { const days = Math.round( (new Date(vacEndInput.value) - new Date(vacStartInput.value)) / 86400000 ) + 1; vacWeeksInput.value = Math.round(days / 7) || ''; } } vacWeeksInput.addEventListener('input', calcVacEnd); vacStartInput.addEventListener('change', () => { if (vacWeeksInput.value) calcVacEnd(); }); vacEndInput.addEventListener('change', calcVacWeeks); async function populateVacEmpSelect() { const sel = document.getElementById('vac-emp'); if (sel.options.length > 1) return; try { const r = await fetch('/api/employees'); if (!r.ok) return; const emps = await r.json(); emps.forEach(e => { const opt = document.createElement('option'); opt.value = e.id; opt.textContent = e.name; sel.appendChild(opt); }); } catch (_) { } } function vacModalClose() { vacModal.classList.add('hidden'); vacModalForm.reset(); editingVacId = null; } async function openVacModal(vacId = null) { editingVacId = vacId; vacModalTitle.textContent = vacId ? 'Редактировать отпуск' : 'Новый отпуск'; vacModalForm.reset(); await populateVacEmpSelect(); if (vacId) { const v = allVacations.find(x => x.id === vacId); if (v) { document.getElementById('vac-emp').value = v.employee_id; vacStartInput.value = v.start_date || ''; vacEndInput.value = v.end_date || ''; calcVacWeeks(); document.getElementById('vac-type').value = v.v_type || 'Летний'; document.getElementById('vac-working').checked = !!v.is_working; } } vacModal.classList.remove('hidden'); document.getElementById('vac-emp').focus(); } document.getElementById('vac-modal-close').addEventListener('click', vacModalClose); document.getElementById('vac-modal-cancel').addEventListener('click', vacModalClose); vacModal.addEventListener('click', e => { if (e.target === vacModal) vacModalClose(); }); btnAddVac.addEventListener('click', () => openVacModal(null)); btnEditVac.addEventListener('click', () => { if (selectedVacId) openVacModal(selectedVacId); }); vacModalForm.addEventListener('submit', async e => { e.preventDefault(); const empId = parseInt(document.getElementById('vac-emp').value, 10); if (!empId || !vacStartInput.value || !vacEndInput.value) return; const body = { employee_id: empId, start_date: vacStartInput.value, end_date: vacEndInput.value, v_type: document.getElementById('vac-type').value || 'Летний', is_working: document.getElementById('vac-working').checked ? 1 : 0, }; vacModalSubmit.disabled = true; vacModalSubmit.textContent = 'Сохранение…'; try { const url = editingVacId ? `/api/vacations/${editingVacId}` : '/api/vacations'; const method = editingVacId ? 'PUT' : 'POST'; const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); vacModalClose(); tabLoaded.vacations = false; await loadVacations(); } catch (err) { alert(`Ошибка сохранения:\n${err.message}`); } finally { vacModalSubmit.disabled = false; vacModalSubmit.textContent = 'Сохранить'; } }); btnDeleteVac.addEventListener('click', async () => { if (!selectedVacId) return; const v = allVacations.find(x => x.id === selectedVacId); if (!confirm(`Удалить отпуск сотрудника «${v?.emp_name || selectedVacId}»?`)) return; try { const r = await fetch(`/api/vacations/${selectedVacId}`, { method: 'DELETE' }); if (!r.ok && r.status !== 204) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || `HTTP ${r.status}`); } tabLoaded.vacations = false; await loadVacations(); } catch (err) { alert(`Ошибка удаления:\n${err.message}`); } }); // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── // CALENDAR // ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── ── const calContainer = document.getElementById('calendar-container'); const calChip = document.getElementById('chip-cal'); const calDeptSel = document.getElementById('filter-cal-dept'); const calMonthSel = document.getElementById('filter-cal-month'); const calYearSel = document.getElementById('filter-cal-year'); const CAL_MONTH_NAMES = [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь', ]; let calYear = new Date().getFullYear(); let calMonth = new Date().getMonth() + 1; let calDept = ''; let calPending = false; let calViewMode = 'presence'; // 'presence' | 'heatmap' let _calPerfCache = null; // {isoDate: {empName: total}} — loaded on demand // Brush (drag-to-select) state let brushActive = false; let brushCells = new Map(); // key: `${empId}_${date}` → {empId, date, td} // Populate year/month selects on init ; (function initCalPeriod() { const curYear = new Date().getFullYear(); for (let y = curYear - 2; y <= curYear + 2; y++) { const opt = document.createElement('option'); opt.value = y; opt.textContent = y; if (y === calYear) opt.selected = true; calYearSel.appendChild(opt); } CAL_MONTH_NAMES.forEach((name, i) => { const opt = document.createElement('option'); opt.value = i + 1; opt.textContent = name; if (i + 1 === calMonth) opt.selected = true; calMonthSel.appendChild(opt); }); })(); // Populate dept filter (reuse departments already loaded for employees) async function loadCalDepartments() { try { const r = await fetch('/api/employees/departments'); if (!r.ok) return; const depts = await r.json(); depts.forEach(d => { const opt = document.createElement('option'); opt.value = d; opt.textContent = d; calDeptSel.appendChild(opt); }); } catch (_) { } } loadCalDepartments(); /** * Fetch /api/workload for every day in `days` in parallel and populate * _calPerfCache[isoDate][empName] = total. */ async function _loadCalPerfMonth(days) { const results = await Promise.allSettled( days.map(d => fetch('/api/workload?date=' + d.iso).then(r => r.ok ? r.json() : null)) ); const cache = {}; days.forEach((d, i) => { const res = results[i]; const dayMap = {}; if (res.status === 'fulfilled' && res.value) { (res.value.employees || []).forEach(e => { dayMap[e.name] = e.total; }); } cache[d.iso] = dayMap; }); _calPerfCache = cache; } /** * Daily performance total (service count) for heatmap mode. * Returns null when the cache isn't loaded or the employee has no record. * Returns 0 when the employee worked but closed 0 cases. */ function getDailyPerformance(empId, isoDate) { if (!_calPerfCache) return null; const dayMap = _calPerfCache[isoDate]; if (!dayMap) return null; const emp = (_calData.employees || []).find(e => e.id === empId); if (!emp) return null; const total = dayMap[emp.name]; return typeof total === 'number' ? total : null; } async function loadCalendar() { _calPerfCache = null; // invalidate on every month reload setChip(calChip, 'running', 'Загрузка…'); calContainer.innerHTML = '<p style="padding:16px;color:var(--color-text-muted);">Загрузка данных…</p>'; const deptParam = calDept ? `&department=${encodeURIComponent(calDept)}` : ''; try { const r = await fetch(`/api/calendar/grid?year=${calYear}&month=${calMonth}${deptParam}`); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); if (calViewMode === 'heatmap') { await _loadCalPerfMonth(data.days); } renderCalendar(data); tabLoaded.calendar = true; setChip(calChip, 'done', `${data.employees.length} сотр.`); const dateInput = document.getElementById('cal-info-date'); if (dateInput && !dateInput._calListenerWired) { dateInput._calListenerWired = true; dateInput.addEventListener('change', () => { if (dateInput.value) selectCalendarDay(dateInput.value); }); } } catch (err) { calContainer.innerHTML = errBlock(err.message); setChip(calChip, 'error', 'Ошибка'); tabLoaded.calendar = false; } } function renderCalendar(data) { if (!data.employees || !data.employees.length) { calContainer.innerHTML = emptyMsg('Нет сотрудников для отображения.'); return; } _calData = data; _calCompMap = null; // recompute on new month _calBaseline = null; const { days, employees, cells, fact_counts, plan_counts } = data; // ── Coverage map: count employees on duty per day ────────────────────────── // "On duty" = plan-working AND not on vacation AND not absent const coverageByDay = {}; days.forEach(d => { coverageByDay[d.iso] = 0; }); employees.forEach(emp => { const empCells = cells[emp.id] || {}; days.forEach(d => { const cell = empCells[d.iso] || {}; if (cell.is_plan_work && cell.status !== 'О' && cell.status !== 'Н') { coverageByDay[d.iso]++; } }); }); // ── Table skeleton ───────────────────────────────────────────────────────── let html = '<table class="cal-table">'; html += '<colgroup><col>'; days.forEach(() => { html += '<col style="width:30px;min-width:30px;max-width:30px">'; }); html += '<col style="width:50px;min-width:50px;max-width:50px"></colgroup>'; // ── Header ───────────────────────────────────────────────────────────────── html += '<thead><tr>'; html += '<th class="cal-name-col"><div class="fixed-name">Сотрудник</div></th>'; days.forEach(d => { let cls = ''; if (d.is_weekend) cls += ' cal-hdr-weekend weekend-col'; // shaded + dark header if (d.is_today) cls += ' cal-hdr-today'; html += `<th class="${cls.trim()}" title="${d.iso}" data-date="${d.iso}">${d.day}<br><small>${d.weekday_name}</small></th>`; }); html += '<th class="cal-total-col">Ф/П</th>'; html += '</tr></thead>'; // ── Body rows ────────────────────────────────────────────────────────────── html += '<tbody>'; employees.forEach(emp => { const empCells = cells[emp.id] || {}; const fact = fact_counts[emp.id] || 0; const plan = plan_counts[emp.id] || 0; html += '<tr>'; const dept = emp.department || ''; html += `<td class="cal-name-col" title="${esc(emp.name)}${dept ? ' · ' + esc(dept) : ''}">` + `<div class="fixed-name">${esc(formatFioToInitials(emp.name))}</div>` + (dept ? `<div class="cal-emp-dept">${esc(dept)}</div>` : '') + `</td>`; days.forEach(d => { const cell = empCells[d.iso] || {}; // Tooltip (shared between modes) const tipParts = []; if (cell.is_vac) tipParts.push('Отпуск'); if (cell.status === 'Н') tipParts.push('Отсутствует'); if (cell.is_fact) tipParts.push('Есть задачи'); if (cell.is_holiday) tipParts.push('Гос. праздник'); if (!cell.is_plan_work) tipParts.push('Выходной'); const tip = tipParts.join(' · ') || (cell.is_plan_work ? 'Рабочий день' : 'Выходной'); let tdClass = ''; let tdStyle = ''; let inner = ''; // Weekend shading + today border apply in both modes if (d.is_weekend) tdClass += 'weekend-col'; if (d.is_today) tdClass += (tdClass ? ' ' : '') + 'cal-day-today'; if (calViewMode === 'heatmap') { const isWorking = cell.is_plan_work && !['О', 'Н', 'К', 'Б'].includes(cell.status); if (isWorking) { const perf = getDailyPerformance(emp.id, d.iso); if (perf === null) { tdStyle = 'background-color:#f0f4f8'; inner = '<span style="color:#b0b8c0;font-size:10px;">–</span>'; } else { const alpha = 0.10 + (perf / 12) * 0.80; const fg = alpha > 0.45 ? '#fff' : '#1a1a2e'; tdStyle = `background-color:rgba(25,135,84,${alpha.toFixed(2)})`; inner = `<span style="color:${fg};font-weight:700;">${perf}</span>`; } } else { tdStyle = 'background-color:#f4f5f6'; const sym = cell.status || (cell.is_plan_work ? 'Р' : 'В'); inner = `<span style="color:#c0c4cb;">${sym}</span>`; } } else { // Presence mode — original logic tdClass += (tdClass ? ' ' : '') + (cell.is_plan_work ? 'cal-day-work' : 'cal-day-off'); if (cell.status === 'О') tdClass += ' cal-day-vac'; else if (cell.status === 'Н') tdClass += ' cal-day-absent'; else if (cell.status === 'К') tdClass += ' cal-day-trip'; else if (cell.status === 'Б') tdClass += ' cal-day-sick'; if (cell.status === 'О') inner = '<span class="cal-sym-vac">О</span>'; else if (cell.status === 'Н') inner = '<span class="cal-sym-absent">Н</span>'; else if (cell.status === 'К') inner = '<span class="cal-sym-trip">К</span>'; else if (cell.status === 'Б') inner = '<span class="cal-sym-sick">Б</span>'; else if (cell.is_plan_work) inner = '<span class="cal-sym-work">Р</span>'; else inner = '<span class="cal-sym-off">В</span>'; } const styleAttr = tdStyle ? ` style="${tdStyle}"` : ''; html += `<td class="${tdClass.trim()}"${styleAttr} data-emp="${emp.id}" data-date="${d.iso}" title="${esc(tip)}">${inner}</td>`; }); html += `<td class="cal-total-col" title="Факт / План">${fact} / ${plan}</td>`; html += '</tr>'; }); html += '</tbody>'; // ── Coverage footer ──────────────────────────────────────────────────────── html += '<tfoot><tr>'; html += '<td class="cal-name-col cal-tfoot-label">Работает сотрудников</td>'; days.forEach(d => { const cnt = coverageByDay[d.iso]; let cls = 'cal-tfoot-day'; if (d.is_weekend) cls += ' weekend-col'; // Smart highlight: only flag working days with low coverage if (!d.is_weekend) { if (cnt === 0) cls += ' cal-cover-zero'; else if (cnt <= 1) cls += ' cal-cover-low'; } const tip = `${d.iso}: ${cnt} сотр. на смене`; html += `<td class="${cls}" title="${esc(tip)}">${cnt}</td>`; }); html += '<td class="cal-total-col"></td>'; html += '</tr></tfoot>'; html += '</table>'; calContainer.innerHTML = html; _autoSelectCalDay(days); } // ── Calendar brush (drag-to-select) ────────────────────────────────────────── const _brushMenu = document.getElementById('cal-brush-menu'); function _brushAddCell(td) { const key = `${td.dataset.emp}_${td.dataset.date}`; if (!brushCells.has(key)) { brushCells.set(key, { empId: parseInt(td.dataset.emp, 10), date: td.dataset.date, td }); td.classList.add('cell-selected'); } } function _brushShowMenu(x, y) { _brushMenu.style.left = Math.min(x + 6, window.innerWidth - 220) + 'px'; _brushMenu.style.top = Math.min(y + 6, window.innerHeight - 130) + 'px'; _brushMenu.classList.remove('hidden'); } function _brushClose() { _brushMenu.classList.add('hidden'); brushCells.forEach(({ td }) => td.classList.remove('cell-selected')); brushCells.clear(); } // Prevent text selection while dragging cells calContainer.addEventListener('mousedown', e => { const td = e.target.closest('td[data-emp]'); if (!td) return; e.preventDefault(); brushActive = true; brushCells.clear(); _brushAddCell(td); }); // Extend selection as cursor moves over other cells calContainer.addEventListener('mouseover', e => { if (!brushActive) return; const td = e.target.closest('td[data-emp]'); if (td) _brushAddCell(td); }); // End drag anywhere — show menu if at least one cell selected document.addEventListener('mouseup', e => { if (!brushActive) return; brushActive = false; if (brushCells.size > 0) _brushShowMenu(e.clientX, e.clientY); }); // Escape cancels the menu and clears selection document.addEventListener('keydown', e => { if (e.key === 'Escape') _brushClose(); }); // Brush menu action buttons _brushMenu.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', async () => { const action = btn.dataset.action; if (action === 'cancel') { _brushClose(); return; } const cells = Array.from(brushCells.values()).map(({ empId, date }) => ({ employee_id: empId, date, })); _brushClose(); if (!cells.length || calPending) return; calPending = true; try { const r = await fetch('/api/calendar/batch-set', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cells, action }), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); tabLoaded.calendar = false; await loadCalendar(); } catch (err) { alert(`Ошибка применения статуса:\n${err.message}`); } finally { calPending = false; } }); }); // ── Calendar sidebar interactivity ─────────────────────────────────────────── let _calData = null; let _calCompMap = null; // Map<"dim:val", Set<empId>> — built once per month let _calBaseline = null; // Map<"dim:val", avgCount> — built once per month let _selectedCalDay = null; function selectCalendarDay(iso) { _selectedCalDay = iso; _highlightCalDay(iso); updateCalendarSidebar(iso); const dateInput = document.getElementById('cal-info-date'); if (dateInput && dateInput.value !== iso) dateInput.value = iso; } function _highlightCalDay(iso) { calContainer.querySelectorAll('.cal-col-selected').forEach(el => el.classList.remove('cal-col-selected')); calContainer.querySelectorAll(`[data-date="${iso}"]`).forEach(el => el.classList.add('cal-col-selected')); } // ── Competency helpers ──────────────────────────────────────────────────────── function _matchEmpByName(ruleName, employees) { let emp = employees.find(e => e.name === ruleName); if (emp) return emp.id; const normRule = formatFioToInitials(ruleName).toLowerCase(); emp = employees.find(e => formatFioToInitials(e.name).toLowerCase() === normRule); if (emp) return emp.id; const surname = ruleName.toLowerCase().split(/\s+/)[0]; emp = employees.find(e => e.name.toLowerCase().split(/\s+/)[0] === surname); return emp ? emp.id : null; } /** Build Map<"dim:val", Set<empId>> from distribution rules. */ function getCompetencyExpertMap(employees) { const map = new Map(); _distRulesData.forEach(rule => { const empId = _matchEmpByName(rule.expert_name, employees); if (empId === null) return; ['service_types', 'regions', 'risks'].forEach(field => { const f = rule[field]; if (!f || f.type !== 'only' || !f.values?.length) return; f.values.forEach(val => { const key = `${field}:${val}`; if (!map.has(key)) map.set(key, new Set()); map.get(key).add(empId); }); }); }); return map; } /** True if the day is a weekend or a production calendar holiday (no one is plan_work). */ function _isDayOff(d) { if (d.is_weekend) return true; const { employees, cells } = _calData; return !employees.some(emp => (cells[emp.id] || {})[d.iso]?.is_plan_work); } /** Average working expert count per competency across WORKING days of current month. */ function calculateCompetencyBaseline(competencyMap) { if (!_calData) return new Map(); const { days, cells } = _calData; const workDays = days.filter(d => !_isDayOff(d)); const denom = Math.max(workDays.length, 1); const baseline = new Map(); for (const [key, empIds] of competencyMap) { let total = 0; workDays.forEach(d => { let count = 0; empIds.forEach(empId => { const cell = (cells[empId] || {})[d.iso] || {}; if (cell.is_plan_work && !['О', 'Н', 'К', 'Б'].includes(cell.status)) count++; }); total += count; }); baseline.set(key, total / denom); } return baseline; } /** Return deficit alerts for iso + up to 3 forward WORKING days. */ function analyzeCompetencyDeficit(iso, competencyMap, baseline) { if (!_calData || !competencyMap.size) return []; const { days, cells } = _calData; const startIdx = days.findIndex(d => d.iso === iso); if (startIdx === -1) return []; // Collect up to 4 working days starting from selected day (inclusive) const targetDays = []; for (let i = startIdx; i < days.length && targetDays.length < 4; i++) { if (!_isDayOff(days[i])) targetDays.push(days[i]); } const dimLabel = { service_types: 'Услуга', regions: 'Регион', risks: 'Риск' }; const alerts = []; for (const d of targetDays) { const [, mm, dd] = d.iso.split('-'); const dateLabel = `${parseInt(dd, 10)} ${MONTH_GEN[parseInt(mm, 10) - 1]}`; for (const [key, empIds] of competencyMap) { const norm = baseline.get(key) || 0; if (norm < 1) continue; let fact = 0; empIds.forEach(empId => { const cell = (cells[empId] || {})[d.iso] || {}; if (cell.is_plan_work && !['О', 'Н', 'К', 'Б'].includes(cell.status)) fact++; }); if (fact < norm * 0.5) { const [dim, ...rest] = key.split(':'); alerts.push({ dateLabel, dimLabel: dimLabel[dim] || dim, val: rest.join(':'), fact: Math.round(fact * 10) / 10, norm: Math.round(norm * 10) / 10, }); } } } return alerts; } // ── Main sidebar update (async — may lazily load distribution rules) ────────── async function updateCalendarSidebar(iso) { if (!_calData) return; const { days, employees, cells } = _calData; // Update title const [, mm, dd] = iso.split('-'); const titleEl = document.getElementById('calendar-summary-date'); if (titleEl) titleEl.textContent = `📊 Сводка на: ${parseInt(dd, 10)} ${MONTH_GEN[parseInt(mm, 10) - 1]}`; // Lazily load distribution rules if not yet fetched if (_distRulesData.length === 0) { try { const r = await fetch('/api/distribution/rules'); _distRulesData = await r.json() || []; } catch (_) { } } // Build competency map + baseline once per month if (!_calCompMap) { _calCompMap = getCompetencyExpertMap(employees); _calBaseline = calculateCompetencyBaseline(_calCompMap); } // Smart deficit alerts const deficits = analyzeCompetencyDeficit(iso, _calCompMap, _calBaseline); // Per-day status tallies + department grouping const totals = { work: 0, off: 0, vac: 0, absent: 0, trip: 0, sick: 0 }; const deptMap = new Map(); employees.forEach(emp => { const dept = emp.department || '—'; if (!deptMap.has(dept)) deptMap.set(dept, { work: [], off: [], vac: [], absent: [], trip: [], sick: [] }); const cell = (cells[emp.id] || {})[iso] || {}; const rec = deptMap.get(dept); const name = formatFioToInitials(emp.name); if (cell.status === 'О') { rec.vac.push(name); totals.vac++; } else if (cell.status === 'Н') { rec.absent.push(name); totals.absent++; } else if (cell.status === 'К') { rec.trip.push(name); totals.trip++; } else if (cell.status === 'Б') { rec.sick.push(name); totals.sick++; } else if (cell.is_plan_work) { rec.work.push(name); totals.work++; } else { rec.off.push(name); totals.off++; } }); // Render alerts container: smart alerts + stats const alertsEl = document.getElementById('calendar-alerts-container'); if (alertsEl) { let html = ''; // Smart alerts block if (deficits.length > 0) { html += deficits.map(a => `<div class="cal-smart-alert">⚠️ <strong>${a.dateLabel}:</strong> Нехватка по направлению <b>${a.dimLabel}: ${esc(a.val)}</b> (работает ${a.fact}, обычно ~${a.norm})</div>` ).join(''); } else if (_calCompMap.size > 0) { html += '<div class="cal-smart-ok">✅ Ближайшие рабочие дни покрыты резервом</div>'; } // Divider + day stats const statRows = [ { cls: 'cal-sym-work', sym: 'Р', label: 'Работают', count: totals.work }, { cls: 'cal-sym-off', sym: 'В', label: 'Выходной', count: totals.off }, { cls: 'cal-sym-vac', sym: 'О', label: 'Отпуск', count: totals.vac }, { cls: 'cal-sym-sick', sym: 'Б', label: 'Больничный', count: totals.sick }, { cls: 'cal-sym-absent', sym: 'Н', label: 'Неявка', count: totals.absent }, { cls: 'cal-sym-trip', sym: 'К', label: 'Командировка', count: totals.trip }, ].filter(r => r.count > 0); if (statRows.length) { if (html) html += '<div class="cal-sb-divider"></div>'; html += statRows.map(r => `<div class="cal-sb-stat-row"><span class="${r.cls}">${r.sym}</span> ${r.label}: <strong>${r.count}</strong></div>` ).join(''); } alertsEl.innerHTML = html || '<div class="cal-sb-placeholder">Нет данных</div>'; } // Departments accordion const accordEl = document.getElementById('calendar-departments-accordion'); if (accordEl) { const sorted = [...deptMap.entries()].sort((a, b) => a[0].localeCompare(b[0], 'ru')); accordEl.innerHTML = sorted.map(([dept, rec]) => { const total = Object.values(rec).reduce((s, a) => s + a.length, 0); const rows = [ { cls: 'cal-sym-work', sym: 'Р', label: 'Работают', names: rec.work }, { cls: 'cal-sym-off', sym: 'В', label: 'Выходной', names: rec.off }, { cls: 'cal-sym-vac', sym: 'О', label: 'Отпуск', names: rec.vac }, { cls: 'cal-sym-sick', sym: 'Б', label: 'Больничный', names: rec.sick }, { cls: 'cal-sym-absent', sym: 'Н', label: 'Неявка', names: rec.absent }, { cls: 'cal-sym-trip', sym: 'К', label: 'Командировка', names: rec.trip }, ].filter(r => r.names.length > 0); const bodyHtml = rows.map(r => `<div class="cal-acc-grp"><span class="${r.cls}">${r.sym}</span> <span class="cal-acc-label">${r.label}:</span> <span class="cal-acc-names">${r.names.map(esc).join(', ')}</span></div>` ).join(''); const workPct = total > 0 ? Math.round(rec.work.length / total * 100) : 0; return `<details class="cal-acc-item"> <summary class="cal-acc-hdr"><span class="cal-acc-dept">${esc(dept)}</span><span class="cal-acc-badge">${rec.work.length}/${total}</span></summary> <div class="cal-acc-progress"><div class="cal-acc-progress-bar" style="width:${workPct}%"></div></div> <div class="cal-acc-body">${bodyHtml}</div> </details>`; }).join(''); } } // Auto-select today or the previously selected day after each render function _autoSelectCalDay(days) { const todayDay = days.find(d => d.is_today); const prevInMonth = days.find(d => d.iso === _selectedCalDay); const iso = (prevInMonth || todayDay || days[0])?.iso; if (iso) selectCalendarDay(iso); } // Click delegation — set up once (calContainer persists across re-renders) calContainer.addEventListener('click', e => { const el = e.target.closest('th[data-date], td[data-date]'); if (!el) return; if (el.tagName === 'TD' && brushCells.size > 1) return; // skip multi-cell drag end selectCalendarDay(el.dataset.date); }); // ── View mode toggle ───────────────────────────────────────────────────────── document.getElementById('cal-view-toggle').addEventListener('click', async e => { const btn = e.target.closest('.cal-view-btn'); if (!btn || btn.classList.contains('active')) return; document.querySelectorAll('.cal-view-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); calViewMode = btn.dataset.mode; if (_calData) { if (calViewMode === 'heatmap' && !_calPerfCache) { setChip(calChip, 'running', 'Загрузка эффективности…'); await _loadCalPerfMonth(_calData.days); setChip(calChip, 'done', `${_calData.employees.length} сотр.`); } renderCalendar(_calData); } }); // ── Month navigation ────────────────────────────────────────────────────────── document.getElementById('btn-cal-prev').addEventListener('click', () => { if (calMonth === 1) { calMonth = 12; calYear--; } else calMonth--; calMonthSel.value = calMonth; calYearSel.value = calYear; tabLoaded.calendar = false; loadCalendar(); }); document.getElementById('btn-cal-next').addEventListener('click', () => { if (calMonth === 12) { calMonth = 1; calYear++; } else calMonth++; calMonthSel.value = calMonth; calYearSel.value = calYear; tabLoaded.calendar = false; loadCalendar(); }); calMonthSel.addEventListener('change', () => { calMonth = parseInt(calMonthSel.value, 10); tabLoaded.calendar = false; loadCalendar(); }); calYearSel.addEventListener('change', () => { calYear = parseInt(calYearSel.value, 10); tabLoaded.calendar = false; loadCalendar(); }); calDeptSel.addEventListener('change', () => { calDept = calDeptSel.value; tabLoaded.calendar = false; loadCalendar(); }); // ── Workload tab — Chart.js dashboard ──────────────────────────────────────── const wlChip = document.getElementById('chip-wl'); const btnRefreshWl = document.getElementById('btn-refresh-wl'); const wlBtnDash = document.getElementById('wl-btn-dashboard'); const wlBtnDetail = document.getElementById('wl-btn-detail'); const wlDashView = document.getElementById('wl-dashboard-view'); const wlDetailView = document.getElementById('wl-detail-view'); const wlNeSelect = document.getElementById('wl-filter-ne'); const wlDeptSelect = document.getElementById('wl-filter-dept'); let wlChartDay = null; let wlChartWeek = null; let wlMode = 'dashboard'; // Chart.js colour palette matching desktop (gray / light-gray / blue) const WL_BG = ['rgba(108,117,125,.70)', 'rgba(206,212,218,.85)', 'rgba(77,171,247,.85)']; const WL_BORD = ['rgba(108,117,125,1)', 'rgba(180,190,196,1)', 'rgba(29,142,220,1)']; async function populateWlDeptSelect() { try { const depts = await fetch('/api/employees/departments').then(r => r.json()); wlDeptSelect.innerHTML = '<option value="">Все отделы</option>' + depts.map(d => `<option value="${esc(d)}">${esc(d)}</option>`).join(''); } catch (_) { } } wlBtnDash.addEventListener('click', () => { if (wlMode === 'dashboard') return; wlMode = 'dashboard'; wlBtnDash.classList.add('active'); wlBtnDetail.classList.remove('active'); wlDashView.style.display = ''; wlDetailView.style.display = 'none'; tabLoaded.workload = false; loadWorkload(); }); wlBtnDetail.addEventListener('click', () => { if (wlMode === 'detail') return; wlMode = 'detail'; wlBtnDetail.classList.add('active'); wlBtnDash.classList.remove('active'); wlDetailView.style.display = ''; wlDashView.style.display = 'none'; tabLoaded.workload = false; loadWorkload(); }); wlNeSelect.addEventListener('change', () => { tabLoaded.workload = false; loadWorkload(); }); wlDeptSelect.addEventListener('change', () => { tabLoaded.workload = false; loadWorkload(); }); btnRefreshWl.addEventListener('click', () => { tabLoaded.workload = false; loadWorkload(); }); async function loadWorkload() { if (tabLoaded.workload) return; setChip(wlChip, 'running', 'Загрузка…'); btnRefreshWl.disabled = true; try { if (wlMode === 'dashboard') { const params = new URLSearchParams(); if (wlNeSelect.value) params.set('ne', wlNeSelect.value); if (wlDeptSelect.value) params.set('department', wlDeptSelect.value); const qs = params.toString(); const url = '/api/workload/dashboard' + (qs ? '?' + qs : ''); const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); tabLoaded.workload = true; renderWorkload(data); setChip(wlChip, 'done', 'Готово'); } else { const r = await fetch('/api/workload'); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); tabLoaded.workload = true; renderWorkloadDetail(data); setChip(wlChip, 'done', 'Готово'); } } catch (e) { setChip(wlChip, 'error', e.message); if (wlMode === 'dashboard') { document.getElementById('wl-leaders-day-body').innerHTML = errBlock(e.message); document.getElementById('wl-leaders-week-body').innerHTML = ''; } else { document.getElementById('wl-detail-container').innerHTML = errBlock(e.message); } } finally { btnRefreshWl.disabled = false; } } function renderWorkload(data) { // Update chart in-place if it exists (smooth animation), recreate otherwise function applyChart(existingChart, canvasId, wrapEl, chartInfo) { if (!chartInfo.labels.length) { if (existingChart) existingChart.destroy(); wrapEl.innerHTML = '<p style="padding:20px;color:var(--color-text-muted);text-align:center">Нет данных</p>'; return null; } if (!document.getElementById(canvasId)) { wrapEl.innerHTML = `<canvas id="${canvasId}"></canvas>`; existingChart = null; } const datasets = chartInfo.datasets.map(ds => ({ label: ds.label, data: ds.data, backgroundColor: WL_BG[ds.color_idx] || WL_BG[0], borderColor: WL_BORD[ds.color_idx] || WL_BORD[0], borderWidth: 1, })); if (existingChart) { existingChart.data.labels = chartInfo.labels; existingChart.data.datasets = datasets; existingChart.update(); return existingChart; } return new Chart(document.getElementById(canvasId).getContext('2d'), { type: 'bar', data: { labels: chartInfo.labels, datasets }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 11 }, boxWidth: 14 } } }, scales: { x: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,.05)' } }, y: { ticks: { font: { size: 11 } }, grid: { display: false } }, }, }, }); } document.getElementById('wl-day-title').textContent = data.chart_day.title || 'График дня'; document.getElementById('wl-week-title').textContent = data.chart_week.title || 'График недели'; wlChartDay = applyChart(wlChartDay, 'wl-chart-day', document.getElementById('wl-wrap-day'), data.chart_day); wlChartWeek = applyChart(wlChartWeek, 'wl-chart-week', document.getElementById('wl-wrap-week'), data.chart_week); // ── Leaders renderer ──────────────────────────────────────────────────────── function renderLeaders(bodyId, titleId, leaders, fallbackTitle) { const titleEl = document.getElementById(titleId); const bodyEl = document.getElementById(bodyId); if (!leaders) { if (titleEl) titleEl.textContent = fallbackTitle; if (bodyEl) bodyEl.innerHTML = '<span style="color:var(--color-text-muted)">Нет данных</span>'; return; } if (titleEl) titleEl.textContent = leaders.date ? `Лидеры: ${leaders.date}` : `Лидеры: ${leaders.period || fallbackTitle}`; let html = ''; function section(icon, title, items, renderItem) { html += `<div class="wl-ls-title">${icon} ${title}</div><ul class="wl-ls-list">`; if (!items || !items.length) { html += `<li style="color:var(--color-text-muted)">—</li>`; } else { items.forEach(it => { html += `<li>${renderItem(it)}</li>`; }); } html += '</ul>'; } section('🚗', 'Километраж', leaders.km_top, e => `${esc(e.name)}: <strong>${e.val}</strong>`); section('🏗', 'Осмотры ЮЛ (Ч)', leaders.risk_top, e => `${esc(e.name)}: <strong>${e.val}</strong>`); section('🏢', 'Лидеры отделов', leaders.dept_leaders, e => `${esc(e.dept)}: ${esc(e.name)} <strong>(${e.val})</strong>`); html += `<div class="wl-ls-title">⚠️ Менее 3 базовых</div><ul class="wl-ls-list">`; if (!leaders.low_basic || !leaders.low_basic.length) { html += `<li class="wl-ok">✓ Все выполнили 3+</li>`; } else { leaders.low_basic.forEach(e => { html += `<li class="wl-ls-warn">${esc(e.name)} <span style="opacity:.75">(${e.val})</span></li>`; }); } html += '</ul>'; if (leaders.corrections && leaders.corrections.length) { section('🔧', 'Исправления', leaders.corrections, e => `${esc(e.label)}: ${esc(e.name)} <strong>(${e.val})</strong>`); } html += `<div class="wl-ls-title">💤 Нет закрытых услуг</div><ul class="wl-ls-list">`; if (!leaders.no_basic || !leaders.no_basic.length) { html += `<li class="wl-ok">✓ Все работают!</li>`; } else { leaders.no_basic.forEach(n => { html += `<li class="wl-ls-warn">${esc(n)}</li>`; }); } html += '</ul>'; if (bodyEl) bodyEl.innerHTML = html; } renderLeaders('wl-leaders-day-body', 'wl-day-leaders-title', data.leaders_day, 'День'); renderLeaders('wl-leaders-week-body', 'wl-week-leaders-title', data.leaders_week, 'Неделя'); } function renderWorkloadDetail(data) { const container = document.getElementById('wl-detail-container'); if (!data.employees || !data.employees.length) { container.innerHTML = '<p style="color:var(--color-text-muted);padding:20px 0">Нет данных за сегодня</p>'; return; } let html = `<p class="hint" style="margin-bottom:8px;">Данные за ${esc(data.date)} · Всего: ${data.total_all} (базовых: ${data.total_basic})</p>`; html += '<table class="data-table"><thead><tr>'; html += '<th>Сотрудник</th><th>Отдел</th>'; html += '<th style="text-align:center;">Базовые</th>'; html += '<th style="text-align:center;">Всего</th>'; html += '<th style="text-align:center;">Дел</th>'; html += '</tr></thead><tbody>'; data.employees.forEach(e => { html += '<tr>'; html += `<td>${esc(e.name)}</td>`; html += `<td style="font-size:11px;color:var(--color-text-muted);">${esc(e.department || '')}</td>`; html += `<td style="text-align:center;font-weight:700;">${e.basic}</td>`; html += `<td style="text-align:center;">${e.total}</td>`; html += `<td style="text-align:center;color:var(--color-info-text);">${e.cases || 0}</td>`; html += '</tr>'; }); html += '</tbody></table>'; container.innerHTML = html; } // ── Workload Excel upload modal ─────────────────────────────────────────────── const modalWlUpload = document.getElementById('modal-wl-upload'); const btnUploadWl = document.getElementById('btn-upload-wl'); const wlUploadClose = document.getElementById('wl-upload-close'); const wlUploadCancel = document.getElementById('wl-upload-cancel'); const wlUploadZone = document.getElementById('wl-upload-zone'); const wlUploadFileIn = document.getElementById('wl-upload-file'); const wlUploadFilename = document.getElementById('wl-upload-filename'); const wlUploadSubmit = document.getElementById('wl-upload-submit'); const formWlUpload = document.getElementById('form-wl-upload'); let wlUploadFile = null; function openWlUploadModal() { wlUploadFile = null; wlUploadFileIn.value = ''; wlUploadFilename.textContent = ''; wlUploadSubmit.disabled = true; wlUploadZone.classList.remove('dragover'); modalWlUpload.classList.remove('hidden'); } function closeWlUploadModal() { modalWlUpload.classList.add('hidden'); } btnUploadWl.addEventListener('click', openWlUploadModal); wlUploadClose.addEventListener('click', closeWlUploadModal); wlUploadCancel.addEventListener('click', closeWlUploadModal); modalWlUpload.addEventListener('click', e => { if (e.target === modalWlUpload) closeWlUploadModal(); }); function setWlUploadFile(f) { if (!f || !f.name.match(/\.(xlsx|xls)$/i)) return; wlUploadFile = f; wlUploadFilename.textContent = f.name; wlUploadSubmit.disabled = false; } wlUploadFileIn.addEventListener('change', () => setWlUploadFile(wlUploadFileIn.files[0])); wlUploadZone.addEventListener('dragover', e => { e.preventDefault(); wlUploadZone.classList.add('dragover'); }); wlUploadZone.addEventListener('dragleave', () => wlUploadZone.classList.remove('dragover')); wlUploadZone.addEventListener('drop', e => { e.preventDefault(); wlUploadZone.classList.remove('dragover'); setWlUploadFile(e.dataTransfer.files[0]); }); formWlUpload.addEventListener('submit', async e => { e.preventDefault(); if (!wlUploadFile) return; wlUploadSubmit.disabled = true; wlUploadSubmit.textContent = 'Загрузка…'; setChip(wlChip, 'running', 'Загрузка файла…'); try { const fd = new FormData(); fd.append('file', wlUploadFile, wlUploadFile.name); const r = await fetch('/api/upload-workload', { method: 'POST', body: fd }); const result = await r.json(); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); closeWlUploadModal(); setChip(wlChip, 'done', `Загружено ${result.saved} записей за ${result.date}` ); // Reload the dashboard with fresh data tabLoaded.workload = false; loadWorkload(); } catch (err) { setChip(wlChip, 'error', err.message); } finally { wlUploadSubmit.disabled = false; wlUploadSubmit.textContent = 'Загрузить'; } }); // ── Email template helpers ──────────────────────────────────────────────────── async function loadTemplate(type) { try { const r = await fetch(`/api/emails/template?type=${encodeURIComponent(type)}`); if (!r.ok) return null; return await r.json(); } catch (_) { return null; } } async function saveTemplate(type, subject, body) { try { await fetch('/api/emails/template', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, subject, body }), }); } catch (_) { } } // ── Email remind modal ──────────────────────────────────────────────────────── const EMAIL_DEFAULT_SUBJECT = 'Контроль сроков: Ваши дела в работе'; const EMAIL_DEFAULT_BODY = 'Здравствуйте, {ИО}!\n\n' + 'Напоминаем, что у вас в работе находятся следующие дела:\n\n' + '{СПИСОК_ДЕЛ}\n\n' + 'Просьба ускорить расчет.'; const modalEmailRemind = document.getElementById('modal-email-remind'); const emailRemindSubjectEl = document.getElementById('email-remind-subject'); const emailRemindBodyEl = document.getElementById('email-remind-body'); const emailRemindSelExpert = document.getElementById('email-remind-expert-sel'); const emailRemindPreview = document.getElementById('email-remind-preview'); const emailRemindCountEl = document.getElementById('email-remind-count'); const btnEmailRemindSend = document.getElementById('email-remind-send'); // Set fallback defaults on page load (overridden by DB on open) emailRemindSubjectEl.value = EMAIL_DEFAULT_SUBJECT; emailRemindBodyEl.value = EMAIL_DEFAULT_BODY; let _remindData = null; let _remindFetching = false; const btnRemindExperts = document.getElementById('btn-remind-experts'); function closeEmailRemindModal() { modalEmailRemind.classList.add('hidden'); _remindFetching = false; btnRemindExperts.disabled = false; const titleEl = document.getElementById('email-remind-title'); if (titleEl) titleEl.textContent = '📧 Напомнить экспертам о делах'; } document.getElementById('email-remind-close').addEventListener('click', closeEmailRemindModal); document.getElementById('email-remind-cancel').addEventListener('click', closeEmailRemindModal); modalEmailRemind.addEventListener('click', e => { if (e.target === modalEmailRemind) closeEmailRemindModal(); }); function _applyNameVars(tpl, item) { return tpl .replace(/\{ФИО\}/g, item.expert_name || '') .replace(/\{ИО\}/g, item.name_io || item.expert_name || '') .replace(/\{И\}/g, item.name_i || item.expert_name || '') .replace(/\{СПИСОК_ДЕЛ\}/g, item.tasks.map(t => ` • ${t}`).join('\n')); } function updateEmailPreview() { const idx = emailRemindSelExpert.selectedIndex; if (!_remindData || !_remindData.length || idx < 0) { emailRemindPreview.innerHTML = '<span class="email-preview-empty">Нет данных для предпросмотра</span>'; return; } const item = _remindData[idx]; const subject = _applyNameVars(emailRemindSubjectEl.value, item); const body = _applyNameVars(emailRemindBodyEl.value, item); emailRemindPreview.innerHTML = `<div class="email-preview-subject">Тема: ${esc(subject)}</div>` + `<div>${esc(body).replace(/\n/g, '<br>')}</div>`; } [emailRemindSubjectEl, emailRemindBodyEl].forEach(el => el.addEventListener('input', updateEmailPreview) ); emailRemindSelExpert.addEventListener('change', updateEmailPreview); btnRemindExperts.addEventListener('click', async () => { if (_remindFetching) return; _remindFetching = true; btnRemindExperts.disabled = true; // ── Scenario A: checked rows; Scenario B: all rows meeting threshold ────── const checkedCbs = Array.from( dlContainer.querySelectorAll('tbody tr:not([style*="display: none"]) .dl-row-cb:checked') ); const isSelectionMode = checkedCbs.length > 0; let allowedCaseNums; if (isSelectionMode) { const selectedIds = new Set(checkedCbs.map(cb => cb.value)); allowedCaseNums = new Set( allDeadlines.filter(c => selectedIds.has(String(c.id))).map(c => c.case_number) ); } else { const threshold = parseInt(dlThreshold.value, 10); allowedCaseNums = new Set( allDeadlines.filter(c => (c.deadline || 0) >= threshold).map(c => c.case_number) ); } let data; try { const r = await fetch('/api/emails/reminders-data'); if (!r.ok) throw new Error(`HTTP ${r.status}`); data = await r.json(); } catch (e) { alert(`Ошибка загрузки данных:\n${e.message}`); return; } finally { _remindFetching = false; btnRemindExperts.disabled = false; } // Filter API data to only the allowed case numbers data = data.map(item => { const filtered = item.tasks.filter(taskStr => allowedCaseNums.has(taskStr.split(' (')[0].trim())); return filtered.length ? { ...item, tasks: filtered } : null; }).filter(Boolean); if (!data.length) { alert('Нет дел для напоминания: ни одно из выбранных дел не закреплено за экспертами.'); return; } _remindData = data; // Load template from DB (fallback to defaults already in inputs) const tpl = await loadTemplate('reminders'); if (tpl) { emailRemindSubjectEl.value = tpl.subject || EMAIL_DEFAULT_SUBJECT; emailRemindBodyEl.value = tpl.body || EMAIL_DEFAULT_BODY; } // Update modal title to reflect the active mode const titleEl = document.getElementById('email-remind-title'); if (titleEl) { titleEl.textContent = isSelectionMode ? `📧 Рассылка по выбранным делам (${checkedCbs.length} шт.)` : `📧 Общая рассылка по порогу (${dlThreshold.value}+ дн.)`; } emailRemindSelExpert.innerHTML = data.map((item, i) => { const noEmail = item.email ? '' : ' (нет e-mail)'; return `<option value="${i}">${esc(item.expert_name)}${esc(noEmail)} — ${item.tasks.length} дел</option>`; }).join(''); const withEmail = data.filter(d => d.email).length; emailRemindCountEl.textContent = withEmail; updateEmailPreview(); modalEmailRemind.classList.remove('hidden'); }); btnEmailRemindSend.addEventListener('click', async () => { if (!_remindData) return; btnEmailRemindSend.disabled = true; const origHTML = btnEmailRemindSend.innerHTML; btnEmailRemindSend.textContent = 'Отправка…'; try { const r = await fetch('/api/emails/send-reminders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ subject: emailRemindSubjectEl.value, body_template: emailRemindBodyEl.value, expert_names: _remindData.map(d => d.expert_name), }), }); const result = await r.json().catch(() => ({})); if (!r.ok) throw new Error(result.detail || `HTTP ${r.status}`); await saveTemplate('reminders', emailRemindSubjectEl.value, emailRemindBodyEl.value); closeEmailRemindModal(); alert(`Письма успешно отправлены: ${result.sent} из ${_remindData.length} экспертов.`); } catch (err) { alert(`Ошибка отправки:\n${err.message}`); } finally { btnEmailRemindSend.disabled = false; btnEmailRemindSend.innerHTML = origHTML; } }); // ── Economics ───────────────────────────────────────────────────────────────── const _TARIFF_HISTORY_DEFAULTS = { '2025-03': { 'Акт': { higher: 1102, first: 1048, second: 886, tax: 13, revenue: 1800 }, 'Выезд': { higher: 675, first: 611, second: 497, tax: 13, revenue: 1020 }, 'NDISTANCE': { higher: 15, first: 15, second: 15, tax: 13, revenue: 52 }, 'Доп Калька': { higher: 860, first: 625, second: 505, tax: 13, revenue: 1060 }, 'Доп Смета': { higher: 845, first: 705, second: 560, tax: 13, revenue: 1195 }, 'Доп': { higher: 686, first: 660, second: 551, tax: 13, revenue: 1120 }, 'Калька': { higher: 1320, first: 1135, second: 945, tax: 13, revenue: 1930 }, 'Компл. Рец': { higher: 2090, first: 2090, second: 0, tax: 13, revenue: 3550 }, 'Осмотр риск.инж.': { higher: 713, first: 713, second: 713, tax: 0, revenue: 1200 }, 'Отчет для суда': { higher: 705, first: 705, second: 0, tax: 13, revenue: 1200 }, 'Отчет риск.инж.': { higher: 5500, first: 5500, second: 0, tax: 0, revenue: 9075 }, 'Оценка (объекты)': { higher: 420, first: 375, second: 350, tax: 13, revenue: 660 }, 'Оценка (услуга)': { higher: 420, first: 375, second: 350, tax: 13, revenue: 660 }, 'Оценка ЮЛ': { higher: 420, first: 420, second: 0, tax: 0, revenue: 1040 }, 'Оценка': { higher: 420, first: 375, second: 350, tax: 13, revenue: 660 }, 'default': { higher: 0, first: 0, second: 0, tax: 0, revenue: 0 }, }, }; const _CATEGORY_KEYS = { 'Высшая': 'higher', 'Первая': 'first', 'Вторая': 'second' }; const _CATEGORY_LABELS = { higher: 'Высшая', first: 'Первая', second: 'Вторая' }; function _loadTariffHistory() { try { const s = localStorage.getItem('eco_tariff_history'); if (s) { const parsed = JSON.parse(s); if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { const keys = Object.keys(parsed); if (keys.length && typeof parsed[keys[0]] === 'object' && parsed[keys[0]] !== null) { // Micro-migration: rename stale '2025-01' key to '2025-03' if ('2025-01' in parsed && !('2025-03' in parsed)) { parsed['2025-03'] = parsed['2025-01']; delete parsed['2025-01']; try { localStorage.setItem('eco_tariff_history', JSON.stringify(parsed)); } catch (_) { } } else if ('2025-01' in parsed) { delete parsed['2025-01']; try { localStorage.setItem('eco_tariff_history', JSON.stringify(parsed)); } catch (_) { } } return parsed; } } } } catch (_) { } return JSON.parse(JSON.stringify(_TARIFF_HISTORY_DEFAULTS)); } function _persistTariffHistory() { try { localStorage.setItem('eco_tariff_history', JSON.stringify(_TARIFF_HISTORY)); } catch (_) { } } let _TARIFF_HISTORY = _loadTariffHistory(); let _ecoData = []; let _ecoSelectedId = null; let _ecoListenersWired = false; let _ecoAppliedVersion = null; let _scRules = []; let _scListenersWired = false; let _msRecords = []; let _msEmployees = []; let _msTariffs = []; let _msListenersWired = false; let _ecoTariffEditKey = null; let _selectedTariffVersions = new Set(); let _chartDepts = null; let _chartServices = null; function _ecoFmt(n) { return n.toLocaleString('ru-RU', { maximumFractionDigits: 2 }) + ' ₽'; } function _fmtVersionKey(k) { if (!k) return ''; const parts = k.split('-'); if (parts.length < 2) return k; const y = parts[0]; const m = parseInt(parts[1], 10); const names = [ 'Январь','Февраль','Март','Апрель','Май','Июнь', 'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь', ]; return `${names[m - 1] || m} ${y}`; } function _getTariffsForPeriod(yearMonth) { const keys = Object.keys(_TARIFF_HISTORY).sort().reverse(); const match = keys.find(k => k <= yearMonth); return _TARIFF_HISTORY[match || keys[keys.length - 1]]; } function _getVersionKeyForPeriod(yearMonth) { const keys = Object.keys(_TARIFF_HISTORY).sort().reverse(); const match = keys.find(k => k <= yearMonth); return match || keys[keys.length - 1]; } // Returns the full tariff entry object for a service (includes tax, revenue) function _getTariffEntry(serviceName, tariffs) { const low = (serviceName || '').toLowerCase(); for (const [pattern, rates] of Object.entries(tariffs)) { if (pattern === 'default') continue; if (low.includes(pattern.toLowerCase())) { return typeof rates === 'object' ? rates : { higher: rates, first: rates, second: rates, tax: 0, revenue: 0 }; } } const def = tariffs.default; return typeof def === 'object' ? def : { higher: 0, first: 0, second: 0, tax: 0, revenue: 0 }; } function _getTariff(serviceName, catKey, tariffs) { const entry = _getTariffEntry(serviceName, tariffs); const key = catKey || 'first'; return entry[key] ?? entry.first ?? 0; } function _isKmService(name) { const low = (name || '').toLowerCase(); return low.includes('ndistance') || low.includes('пробег'); } function _ecoMonthDates(yearMonth) { const [y, m] = yearMonth.split('-').map(Number); const daysInMonth = new Date(y, m, 0).getDate(); return Array.from({ length: daysInMonth }, (_, i) => `${y}-${String(m).padStart(2, '0')}-${String(i + 1).padStart(2, '0')}` ); } function _resolveServiceKey(baseName, rule) { const low = baseName.toLowerCase(); if (rule === 'NDISTANCE') return 'NDISTANCE'; if (low.includes('оценка юл')) return 'Оценка ЮЛ'; if (!low.includes('оценка')) return baseName; if (rule.includes('ОБЪЕКТ')) return 'Оценка (объекты)'; if (rule.includes('ОЦЕНК') && rule !== 'CNT') return 'Оценка (услуга)'; return 'Оценка'; } async function aggregateEconomicsData(yearMonth) { const empRegistry = {}; allEmployees .filter(e => !e.is_fired) .forEach(e => { const normKey = (e.name || '').trim().toLowerCase(); const catKey = _CATEGORY_KEYS[e.category] || 'first'; const isReso = !!e.is_reso || (e.department || '').toLowerCase().includes('ресо'); empRegistry[normKey] = { name: e.name, dept: e.department || '', catKey, catLabel: _CATEGORY_LABELS[catKey] || 'Первая', isDirectContract: isReso, }; }); const tariffs = _getTariffsForPeriod(yearMonth); const dates = _ecoMonthDates(yearMonth); const settled = await Promise.allSettled( dates.map(date => fetch('/api/workload?date=' + date).then(r => r.ok ? r.json() : null)) ); const empAcc = {}; settled.forEach(res => { if (res.status !== 'fulfilled' || !res.value) return; (res.value.employees || []).forEach(emp => { const normKey = (emp.name || '').trim().toLowerCase(); if (!empRegistry[normKey]) return; if (!empAcc[normKey]) { const info = empRegistry[normKey]; empAcc[normKey] = { name: info.name, dept: info.dept, catKey: info.catKey, catLabel: info.catLabel, isDirectContract: info.isDirectContract, svcMap: {} }; } (emp.services || []).forEach(svc => { const baseName = (svc.name || '').trim(); if (!baseName) return; const rule = (svc.rule || 'CNT').trim().toUpperCase(); const cnt = svc.count || 0; if (cnt <= 0) return; const key = _resolveServiceKey(baseName, rule); empAcc[normKey].svcMap[key] = (empAcc[normKey].svcMap[key] || 0) + cnt; }); }); }); let seq = 1; const result = []; for (const acc of Object.values(empAcc)) { const items = Object.entries(acc.svcMap) .map(([name, qty]) => { const entry = _getTariffEntry(name, tariffs); const rate = entry[acc.catKey] ?? entry.first ?? 0; const taxPct = entry.tax || 0; const revRate = entry.revenue || 0; const gross = qty * rate; const net = gross - (gross * taxPct / 100); const revenue = qty * revRate; const profit = revenue - gross; return { name, qty, rate, taxPct, gross, net, revenue, profit, isKm: _isKmService(name) }; }) .filter(i => i.qty > 0) .sort((a, b) => b.gross - a.gross); if (!items.length) continue; result.push({ id: seq++, name: acc.name, dept: acc.dept, catKey: acc.catKey, catLabel: acc.catLabel, isDirectContract: acc.isDirectContract || false, gross: items.reduce((s, i) => s + i.gross, 0), net: items.reduce((s, i) => s + i.net, 0), revenue: items.reduce((s, i) => s + i.revenue, 0), profit: items.reduce((s, i) => s + i.profit, 0), groups: [{ title: 'Выполненные работы', items }], }); } result.sort((a, b) => b.gross - a.gross); return result; } function _getEcoYearMonth() { const y = document.getElementById('eco-year-sel').value; const m = String(document.getElementById('eco-month-sel').value).padStart(2, '0'); return `${y}-${m}`; } function _initEcoMonthSels() { const yearSel = document.getElementById('eco-year-sel'); const monthSel = document.getElementById('eco-month-sel'); if (yearSel.options.length) return; const now = new Date(); const curY = now.getFullYear(); const curM = now.getMonth() + 1; for (let y = curY - 2; y <= curY + 1; y++) { const opt = document.createElement('option'); opt.value = y; opt.textContent = y; opt.selected = (y === curY); yearSel.appendChild(opt); } const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; MONTH_NAMES.forEach((name, i) => { const opt = document.createElement('option'); opt.value = i + 1; opt.textContent = name; opt.selected = (i + 1 === curM); monthSel.appendChild(opt); }); } function _renderEcoMaster(compareData = []) { const tbody = document.getElementById('eco-master-body'); document.getElementById('eco-emp-count').textContent = _ecoData.length; if (!_ecoData.length) { tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--color-text-muted);padding:20px;">Нет данных за выбранный период</td></tr>'; return; } const cmpMap = {}; compareData.forEach(e => { cmpMap[(e.name || '').trim().toLowerCase()] = e; }); tbody.innerHTML = _ecoData.map(emp => { const cmpEmp = cmpMap[(emp.name || '').trim().toLowerCase()]; let microTrend = ''; if (cmpEmp && cmpEmp.net > 0) { const pct = (emp.net - cmpEmp.net) / cmpEmp.net * 100; const rising = emp.net >= cmpEmp.net; const color = rising ? 'var(--color-success)' : 'var(--color-danger)'; const arrow = rising ? '▲' : '▼'; const sign = rising ? '+' : ''; const pctStr = `${sign}${Math.round(pct)}%`; const prevFmt = Math.round(cmpEmp.net).toLocaleString('ru-RU'); microTrend = `<span class="eco-micro-trend" style="color:${color};" title="Период сравнения: ${prevFmt} ₽">${arrow} ${pctStr}</span>`; } else if (!cmpEmp) { microTrend = '<span class="eco-micro-trend eco-micro-trend--new" title="Нет данных за период сравнения">·</span>'; } const resoBadge = emp.isDirectContract ? ' <span class="eco-badge-reso">РЕСО</span>' : ''; return ` <tr data-eco-id="${emp.id}"> <td><strong>${esc(emp.name)}</strong></td> <td style="color:var(--color-text-muted);font-size:12px;">${esc(emp.dept)}${resoBadge}</td> <td style="text-align:right;white-space:nowrap;font-weight:700;color:var(--color-primary);"> ${_ecoFmt(emp.gross)}${microTrend} </td> </tr>`; }).join(''); } function _updateEcoKpi(compareData = []) { const el = id => document.getElementById(id); const setMoney = (id, val) => { const e = el(id); if (e) e.innerHTML = `${Math.round(val).toLocaleString('ru-RU')} <span class="eco-kpi-unit">₽</span>`; }; if (!_ecoData.length) { ['eco-kpi-revenue', 'eco-kpi-gross', 'eco-kpi-net', 'eco-kpi-profit'].forEach(id => { const e = el(id); if (e) e.innerHTML = '0 <span class="eco-kpi-unit">₽</span>'; }); ['eco-trend-revenue', 'eco-trend-gross', 'eco-trend-net', 'eco-trend-profit'].forEach(id => { const e = el(id); if (e) e.innerHTML = ''; }); const bar = el('eco-margin-bar-wrap'); if (bar) bar.style.display = 'none'; return; } const kpiData = _ecoData.filter(e => !e.isDirectContract); const resoCount = _ecoData.length - kpiData.length; const totRevenue = kpiData.reduce((s, e) => s + (e.revenue || 0), 0); const totGross = kpiData.reduce((s, e) => s + (e.gross || 0), 0); const totNet = kpiData.reduce((s, e) => s + (e.net || 0), 0); const totProfit = kpiData.reduce((s, e) => s + (e.profit || 0), 0); setMoney('eco-kpi-revenue', totRevenue); setMoney('eco-kpi-gross', totGross); setMoney('eco-kpi-net', totNet); setMoney('eco-kpi-profit', totProfit); const resoNote = resoCount > 0 ? `excl. ${resoCount} РЕСО` : ''; const resoSub = resoCount > 0 ? `исключено ${resoCount} РЕСО` : ''; const subRev = el('eco-kpi-revenue-sub'); const subGross = el('eco-kpi-gross-sub'); if (subRev && resoNote) subRev.textContent = resoSub; if (subGross && resoNote) subGross.textContent = resoSub; const cmpKpi = compareData.filter(e => !e.isDirectContract); const hasCmp = cmpKpi.length > 0; const cmpRevenue = cmpKpi.reduce((s, e) => s + (e.revenue || 0), 0); const cmpGross = cmpKpi.reduce((s, e) => s + (e.gross || 0), 0); const cmpNet = cmpKpi.reduce((s, e) => s + (e.net || 0), 0); const cmpProfit = cmpKpi.reduce((s, e) => s + (e.profit || 0), 0); _setEcoTrend('eco-trend-revenue', totRevenue, cmpRevenue, hasCmp, true); _setEcoTrend('eco-trend-gross', totGross, cmpGross, hasCmp, false); _setEcoTrend('eco-trend-net', totNet, cmpNet, hasCmp, false); _setEcoTrend('eco-trend-profit', totProfit, cmpProfit, hasCmp, true); // Margin bar const barWrap = el('eco-margin-bar-wrap'); if (barWrap && totRevenue > 0) { barWrap.style.display = ''; const marginPct = totProfit / totRevenue * 100; const fotPct = totGross / totRevenue * 100; const profitW = Math.max(0, Math.min(100, marginPct)).toFixed(1); const grossW = Math.max(0, Math.min(100 - parseFloat(profitW), fotPct)).toFixed(1); const profitSeg = el('eco-margin-profit-seg'); const grossSeg = el('eco-margin-gross-seg'); const label = el('eco-margin-label'); if (profitSeg) profitSeg.style.width = `${profitW}%`; if (grossSeg) grossSeg.style.width = `${grossW}%`; if (label) label.textContent = `Маржинальность: ${marginPct.toFixed(1)}% · ФОТ: ${fotPct.toFixed(1)}% от выручки · К выплате: ${(totNet / totRevenue * 100).toFixed(1)}%`; } else if (barWrap) { barWrap.style.display = 'none'; } } function _getCompareMonth(yearMonth, mode) { const [y, m] = yearMonth.split('-').map(Number); if (mode === 'prev_year') return `${y - 1}-${String(m).padStart(2, '0')}`; const d = new Date(y, m - 2, 1); // step back one month (JS months are 0-indexed) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } function _setEcoTrend(id, cur, prev, hasPrev, positiveIsGood) { const el = document.getElementById(id); if (!el) return; if (!hasPrev || prev === 0) { el.innerHTML = '<span class="eco-trend-none">— нет данных</span>'; return; } const pct = (cur - prev) / Math.abs(prev) * 100; const rising = pct > 0; const color = rising ? (positiveIsGood ? 'var(--color-success)' : 'var(--color-danger)') : (positiveIsGood ? 'var(--color-danger)' : 'var(--color-success)'); const arrow = rising ? '▲' : '▼'; const sign = rising ? '+' : ''; el.innerHTML = `<span style="color:${color};font-size:12px;font-weight:700;">${arrow} ${sign}${pct.toFixed(1)}%</span>`; } function _prepareEcoChartsData(ecoData) { const deptProfit = {}; const svcProfit = {}; ecoData.forEach(emp => { if (emp.isDirectContract) return; const dept = emp.dept || 'Без отдела'; deptProfit[dept] = (deptProfit[dept] || 0) + (emp.profit || 0); (emp.groups || []).forEach(g => { (g.items || []).forEach(item => { const name = item.name === 'NDISTANCE' ? 'Выезд (километраж)' : item.name; svcProfit[name] = (svcProfit[name] || 0) + (item.profit || 0); }); }); }); const topServices = Object.entries(svcProfit) .sort((a, b) => b[1] - a[1]) .slice(0, 5); return { depts: { labels: Object.keys(deptProfit), data: Object.values(deptProfit) }, services: { labels: topServices.map(([k]) => k), data: topServices.map(([, v]) => v) }, }; } function _renderEcoCharts() { if (_chartDepts) { _chartDepts.destroy(); _chartDepts = null; } if (_chartServices) { _chartServices.destroy(); _chartServices = null; } const { depts, services } = _prepareEcoChartsData(_ecoData); const PALETTE = ['#1a3a6b', '#2e7d32', '#f97316', '#1565c0', '#6d4c41', '#7b1fa2', '#00838f', '#ef6c00']; const fmtRub = v => Math.round(v).toLocaleString('ru-RU') + ' ₽'; const ctxD = document.getElementById('ecoChartDepts'); if (ctxD && depts.labels.length) { _chartDepts = new Chart(ctxD, { type: 'doughnut', data: { labels: depts.labels, datasets: [{ data: depts.data, backgroundColor: PALETTE.slice(0, depts.labels.length), borderWidth: 2, borderColor: '#fff' }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { font: { size: 11, family: '-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif' }, padding: 10 } }, tooltip: { callbacks: { label: ctx => ` ${ctx.label}: ${fmtRub(ctx.parsed)}` } }, }, }, }); } const ctxS = document.getElementById('ecoChartServices'); if (ctxS && services.labels.length) { _chartServices = new Chart(ctxS, { type: 'bar', data: { labels: services.labels, datasets: [{ data: services.data, backgroundColor: '#1a3a6b', hoverBackgroundColor: '#14305a', borderRadius: 4 }], }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => ` ${fmtRub(ctx.parsed.x)}` } }, }, scales: { x: { ticks: { callback: v => Math.round(v).toLocaleString('ru-RU'), font: { size: 11 } }, grid: { color: '#e0e0e0' } }, y: { ticks: { font: { size: 11, family: '-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif' } }, grid: { display: false } }, }, }, }); } } async function _runEcoCalc() { const chip = document.getElementById('eco-chip'); const yearMonth = _getEcoYearMonth(); const compareMode = (document.getElementById('eco-compare-sel') || {}).value || 'prev_month'; const compareYM = _getCompareMonth(yearMonth, compareMode); setChip(chip, 'running', 'Загрузка…'); if (!allEmployees.length) { const r = await fetch('/api/employees'); if (r.ok) allEmployees = await r.json(); } const [curData, cmpData] = await Promise.all([ aggregateEconomicsData(yearMonth), aggregateEconomicsData(compareYM), ]); _ecoData = curData; _ecoAppliedVersion = _getVersionKeyForPeriod(yearMonth); _renderEcoMaster(cmpData); _updateEcoKpi(cmpData); _renderEcoCharts(); const vLabel = _ecoAppliedVersion ? ` · прайс ${_fmtVersionKey(_ecoAppliedVersion)}` : ''; setChip(chip, 'done', `${_ecoData.length} сотр. · ${yearMonth}${vLabel}`); } async function _onEcoMonthChange() { document.getElementById('eco-detail-empty').style.display = ''; document.getElementById('eco-detail-content').style.display = 'none'; document.getElementById('eco-detail-title').textContent = 'Инспектор расчета'; _ecoSelectedId = null; const chip = document.getElementById('eco-chip'); try { await _runEcoCalc(); } catch (err) { setChip(chip, 'error', err.message); document.getElementById('eco-master-body').innerHTML = `<tr><td colspan="3">${errBlock(err.message)}</td></tr>`; } } // ── Tariff Dictionary modal ─────────────────────────────────────────────────── function _openEcoTariffsModal() { const allKeys = Object.keys(_TARIFF_HISTORY).sort(); // First open: default to last 2 versions selected if (_selectedTariffVersions.size === 0 && allKeys.length) { allKeys.slice(-2).forEach(k => _selectedTariffVersions.add(k)); } _ecoTariffEditKey = allKeys[allKeys.length - 1] || null; _renderEcoVersionSel(); _renderTariffVerBar(); _renderEcoTariffsTable(); document.getElementById('modal-eco-tariffs').classList.remove('hidden'); } function _closeEcoTariffsModal() { document.getElementById('modal-eco-tariffs').classList.add('hidden'); const body = document.getElementById('eco-indexation-body'); const arrow = document.getElementById('eco-indexation-arrow'); const form = document.getElementById('eco-new-version-form'); const btn = document.getElementById('btn-eco-new-version'); if (body) body.style.display = 'none'; if (arrow) arrow.textContent = '▾'; if (form) form.style.display = 'none'; if (btn) btn.style.display = ''; } function _renderEcoVersionSel() { const sel = document.getElementById('eco-tariff-version-sel'); const keys = Object.keys(_TARIFF_HISTORY).sort().reverse(); sel.innerHTML = keys.map(k => `<option value="${k}"${k === _ecoTariffEditKey ? ' selected' : ''}>${_fmtVersionKey(k)}</option>` ).join(''); _renderEcoVersionHistory(); } function _renderEcoVersionHistory() { const container = document.getElementById('eco-version-history-list'); if (!container) return; const keys = Object.keys(_TARIFF_HISTORY).sort().reverse(); container.innerHTML = keys.map(k => { const isActive = k === _ecoTariffEditKey; return ` <div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--color-border);"> <span style="font-size:13px;min-width:120px;font-weight:${isActive ? '700' : '400'};color:${isActive ? 'var(--color-primary)' : 'var(--color-text)'};"> Версия от ${_fmtVersionKey(k)} </span> ${isActive ? '<span style="font-size:11px;color:var(--color-success);">✓ редактируется</span>' : `<button class="btn-sm eco-version-load" data-key="${k}" style="padding:2px 10px;font-size:11px;">Открыть</button>` } </div>`; }).join(''); } // Rate field descriptors used when building the multi-version tariff table const _TARIFF_RATE_FIELDS = [ { field: 'higher', label: 'Высшая, ₽', step: 10 }, { field: 'first', label: 'Первая, ₽', step: 10 }, { field: 'second', label: 'Вторая, ₽', step: 10 }, { field: 'tax', label: 'Налог, %', step: 1 }, { field: 'revenue', label: 'Выручка, ₽', step: 10 }, ]; function _renderTariffVerBar() { const bar = document.getElementById('eco-tariff-ver-bar'); if (!bar) return; const allKeys = Object.keys(_TARIFF_HISTORY).sort(); if (!allKeys.length) { bar.innerHTML = ''; return; } const latestKey = allKeys[allKeys.length - 1]; bar.innerHTML = ` <span class="tariff-ver-label">Версии:</span> ${allKeys.map(k => { const isLatest = k === latestKey; const checked = _selectedTariffVersions.has(k) ? 'checked' : ''; return `<label class="tariff-ver-toggle${isLatest ? ' tariff-ver-latest' : ''}"> <input type="checkbox" value="${k}" ${checked}>${_fmtVersionKey(k)}${isLatest ? ' ✎' : ''} </label>`; }).join('')} <button class="btn-sm tariff-print-btn" id="btn-tariff-print" title="Сформировать и распечатать справку для бухгалтерии"> 🖨 Печать для бухгалтерии </button> `; bar.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) _selectedTariffVersions.add(cb.value); else _selectedTariffVersions.delete(cb.value); _renderEcoTariffsTable(); }); }); bar.querySelector('#btn-tariff-print').addEventListener('click', _printTariffReport); } function _printTariffReport() { const allKeys = Object.keys(_TARIFF_HISTORY).sort(); if (!allKeys.length) { alert('Нет данных тарифов для печати.'); return; } const latestKey = allKeys[allKeys.length - 1]; const tariffs = _TARIFF_HISTORY[latestKey] || {}; const versionLabel = _fmtVersionKey(latestKey); const today = new Date().toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); // ── Tariff table rows ─────────────────────────────────────────────────────── const patterns = [ ...Object.keys(tariffs).filter(k => k !== 'default'), ...('default' in tariffs ? ['default'] : []), ]; const tariffRows = patterns.map(pat => { const raw = tariffs[pat]; const r = raw && typeof raw === 'object' ? raw : { higher: raw || 0, first: raw || 0, second: raw || 0, tax: 0, revenue: 0 }; const fmt = n => (n || 0).toLocaleString('ru-RU'); return `<tr> <td>${pat === 'default' ? '<em>default</em>' : pat}</td> <td>${fmt(r.higher)}</td> <td>${fmt(r.first)}</td> <td>${fmt(r.second)}</td> <td>${fmt(r.tax)} %</td> <td>${fmt(r.revenue)}</td> </tr>`; }).join(''); // ── Employee table rows grouped by department ─────────────────────────────── const employees = (allEmployees || []) .filter(e => !e.is_fired) .map(e => ({ name: (e.name || '').trim(), dept: (e.department || '').trim(), catLabel: _CATEGORY_LABELS[_CATEGORY_KEYS[e.category] || 'first'] || 'Первая', })) .sort((a, b) => (a.dept + a.name).localeCompare(b.dept + b.name, 'ru')); const deptMap = {}; employees.forEach(e => { if (!deptMap[e.dept]) deptMap[e.dept] = []; deptMap[e.dept].push(e); }); const empRows = Object.entries(deptMap).map(([dept, emps]) => `<tr class="dept-hdr"><td colspan="2">${dept || 'Без отдела'}</td></tr>` + emps.map(e => `<tr><td>${e.name}</td><td class="cat">${e.catLabel}</td></tr>`).join('') ).join(''); const html = `<!DOCTYPE html> <html lang="ru"><head><meta charset="UTF-8"> <title>Справка по тарифам — ${today}</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; color: #000; padding: 18mm 15mm; } h1 { font-size: 13pt; font-weight: bold; text-align: center; margin-bottom: 3mm; } .sub { text-align: center; font-size: 10pt; color: #555; margin-bottom: 7mm; } h2 { font-size: 11pt; font-weight: bold; border-bottom: 1.5px solid #000; margin: 7mm 0 3mm; padding-bottom: 1mm; } table { border-collapse: collapse; width: 100%; margin-bottom: 5mm; } th, td { border: 1px solid #888; padding: 3px 7px; font-size: 10pt; vertical-align: middle; } th { background: #f2f2f2; font-weight: bold; text-align: center; white-space: nowrap; } td.num, td.cat { text-align: right; white-space: nowrap; } tr.dept-hdr td { background: #e4e4e4; font-weight: bold; font-size: 9.5pt; text-align: left; text-transform: uppercase; letter-spacing: 0.3px; } @media print { body { padding: 10mm; } thead { display: table-header-group; } } </style></head><body> <h1>Справка по тарифам и категориям сотрудников</h1> <p class="sub">Дата: ${today} · Версия тарифов: ${versionLabel}</p> <h2>1. Сетка тарифов</h2> <table> <thead><tr> <th style="text-align:left;">Паттерн</th> <th>Высшая, ₽</th><th>Первая, ₽</th><th>Вторая, ₽</th> <th>Налог</th><th>Выручка, ₽</th> </tr></thead> <tbody>${tariffRows}</tbody> </table> <h2>2. Сотрудники и категории (${employees.length} чел.)</h2> <table> <thead><tr> <th style="text-align:left;">ФИО</th> <th>Категория</th> </tr></thead> <tbody>${empRows}</tbody> </table> </body></html>`; const w = window.open('', '_blank', 'width=920,height=720'); if (!w) { alert('Пожалуйста, разрешите всплывающие окна для этой страницы.'); return; } w.document.write(html); w.document.close(); w.focus(); setTimeout(() => w.print(), 400); } function _renderEcoTariffsTable() { const thead = document.getElementById('eco-tariffs-thead'); const tbody = document.getElementById('eco-tariffs-body'); if (!thead || !tbody) return; const allKeys = Object.keys(_TARIFF_HISTORY).sort(); if (!allKeys.length) return; const latestKey = allKeys[allKeys.length - 1]; _ecoTariffEditKey = latestKey; const latestTariffs = _TARIFF_HISTORY[latestKey] || {}; // Only render selected versions, preserving sort order const vKeys = allKeys.filter(k => _selectedTariffVersions.has(k)); const colCount = 1 + vKeys.length; // Ставка + version cols // ── thead ──────────────────────────────────────────────────────────────── thead.innerHTML = `<tr> <th class="tariff-sticky-1">Ставка</th> ${vKeys.map(k => ` <th class="tariff-ver-th ${k === latestKey ? 'tariff-current-ver' : 'tariff-old-ver'}" data-version="${k}">${_fmtVersionKey(k)}${k === latestKey ? ' <span class="tariff-edit-badge">✎</span>' : ''}</th> `).join('')} <th class="tariff-del-col"></th> </tr>`; // ── tbody ──────────────────────────────────────────────────────────────── tbody.innerHTML = ''; const patterns = [ ...Object.keys(latestTariffs).filter(k => k !== 'default'), ...('default' in latestTariffs ? ['default'] : []), ]; patterns.forEach(pattern => { const isDefault = pattern === 'default'; // Group header row — pattern name spans full width const hdrTr = document.createElement('tr'); hdrTr.className = 'tariff-group-hdr'; hdrTr.dataset.pattern = pattern; const hdrTd = document.createElement('td'); hdrTd.colSpan = colCount + 1; // +1 for del col if (isDefault) { hdrTd.innerHTML = '<em>default</em> — запасная ставка для несовпавших услуг'; } else { hdrTd.textContent = pattern; const btnDel = document.createElement('button'); btnDel.className = 'btn-clear eco-tariff-del'; btnDel.title = 'Удалить паттерн'; btnDel.textContent = '✕'; btnDel.addEventListener('click', () => _deleteEcoTariffPattern(pattern)); hdrTd.appendChild(btnDel); } hdrTr.appendChild(hdrTd); tbody.appendChild(hdrTr); // 5 rate field sub-rows _TARIFF_RATE_FIELDS.forEach(({ field, label, step }) => { const tr = document.createElement('tr'); tr.className = 'tariff-subrow'; tr.dataset.pattern = pattern; tr.dataset.field = field; // Sticky label col const tdLabel = document.createElement('td'); tdLabel.className = 'tariff-sticky-1'; tdLabel.textContent = label; tr.appendChild(tdLabel); // Version value cols vKeys.forEach(vk => { const raw = (_TARIFF_HISTORY[vk] || {})[pattern]; const r = raw && typeof raw === 'object' ? raw : raw !== undefined ? { higher: raw, first: raw, second: raw, tax: 0, revenue: 0 } : null; const val = r ? (r[field] ?? 0) : 0; const td = document.createElement('td'); const isEditable = vk === latestKey; td.className = `tariff-price-cell ${isEditable ? 'tariff-editable' : 'tariff-readonly'}`; td.dataset.version = vk; td.dataset.pattern = pattern; td.dataset.field = field; td.dataset.step = step; const span = document.createElement('span'); span.className = 'tariff-cell-val'; span.textContent = val ? val.toLocaleString('ru-RU') : '—'; td.appendChild(span); tr.appendChild(td); }); // Empty spacer for del col alignment const tdSpacer = document.createElement('td'); tdSpacer.className = 'tariff-del-col'; tr.appendChild(tdSpacer); tbody.appendChild(tr); }); }); _wireInlineEditing(tbody); } // Attach click-to-edit listeners only to editable (latest version) cells function _wireInlineEditing(container) { container.querySelectorAll('.tariff-price-cell.tariff-editable').forEach(td => { td.addEventListener('click', _tariffCellClick); }); } function _tariffCellClick() { const td = this; if (td.querySelector('input')) return; // already in edit mode const span = td.querySelector('.tariff-cell-val'); if (!span) return; const rawText = span.textContent.trim(); const currentNum = rawText === '—' ? 0 : parseFloat(rawText.replace(/\s/g, '').replace(',', '.')) || 0; const input = document.createElement('input'); input.type = 'number'; input.min = '0'; input.step = td.dataset.step || '10'; input.value = currentNum; input.className = 'tariff-inline-input'; td.replaceChild(input, span); input.focus(); input.select(); function _commit() { const newVal = Math.max(0, parseFloat(input.value) || 0); const vk = td.dataset.version; const pattern = td.dataset.pattern; const field = td.dataset.field; if (!_TARIFF_HISTORY[vk]) _TARIFF_HISTORY[vk] = {}; if (!_TARIFF_HISTORY[vk][pattern] || typeof _TARIFF_HISTORY[vk][pattern] !== 'object') { _TARIFF_HISTORY[vk][pattern] = { higher: 0, first: 0, second: 0, tax: 0, revenue: 0 }; } _TARIFF_HISTORY[vk][pattern][field] = newVal; _persistTariffHistory(); span.textContent = newVal ? newVal.toLocaleString('ru-RU') : '—'; td.replaceChild(span, input); } input.addEventListener('blur', _commit); input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _commit(); } if (e.key === 'Escape') { td.replaceChild(span, input); } }); } function _deleteEcoTariffPattern(pattern) { if (!confirm(`Удалить паттерн «${pattern}» из всех версий прайса?`)) return; for (const vk of Object.keys(_TARIFF_HISTORY)) { delete _TARIFF_HISTORY[vk][pattern]; } _persistTariffHistory(); _renderEcoTariffsTable(); } function _addEcoTariffRow() { const raw = (prompt('Введите паттерн (подстрока в названии услуги, без учёта регистра):') || '').trim().toLowerCase(); if (!raw) return; for (const vk of Object.keys(_TARIFF_HISTORY)) { if (!_TARIFF_HISTORY[vk][raw]) { _TARIFF_HISTORY[vk][raw] = { higher: 0, first: 0, second: 0, tax: 0, revenue: 0 }; } } _persistTariffHistory(); _renderEcoTariffsTable(); } function _saveEcoTariffs() { _persistTariffHistory(); _closeEcoTariffsModal(); _onEcoMonthChange(); } function _createEcoVersion() { const monthInput = document.getElementById('eco-new-version-month').value; const coeff = parseFloat(document.getElementById('eco-new-version-coeff').value); if (!monthInput) { alert('Укажите месяц начала действия'); return; } if (isNaN(coeff) || coeff <= 0) { alert('Коэффициент должен быть числом > 0'); return; } if (_TARIFF_HISTORY[monthInput]) { if (!confirm(`Версия от ${_fmtVersionKey(monthInput)} уже существует. Перезаписать?`)) return; } const srcKey = Object.keys(_TARIFF_HISTORY).sort().reverse()[0]; const srcTariffs = JSON.parse(JSON.stringify(_TARIFF_HISTORY[srcKey])); const newTariffs = {}; for (const [pattern, rates] of Object.entries(srcTariffs)) { if (typeof rates === 'object' && rates !== null) { newTariffs[pattern] = { higher: Math.round((rates.higher || 0) * coeff), first: Math.round((rates.first || 0) * coeff), second: Math.round((rates.second || 0) * coeff), tax: rates.tax || 0, revenue: Math.round((rates.revenue || 0) * coeff), }; } else { newTariffs[pattern] = Math.round((rates || 0) * coeff); } } _TARIFF_HISTORY[monthInput] = newTariffs; _persistTariffHistory(); _ecoTariffEditKey = monthInput; _selectedTariffVersions.add(monthInput); _renderEcoVersionSel(); document.getElementById('eco-tariff-version-sel').value = monthInput; _renderTariffVerBar(); _renderEcoTariffsTable(); document.getElementById('eco-new-version-form').style.display = 'none'; document.getElementById('btn-eco-new-version').style.display = ''; } // ── CSV Export ─────────────────────────────────────────────────────────────── function _exportEcoToCSV() { if (!_ecoData.length) { alert('Нет данных для экспорта. Выберите период и дождитесь загрузки.'); return; } const yearMonth = _getEcoYearMonth(); const SEP = ';'; const rows = [ ['ФИО', 'Отдел', 'Категория', 'Тип договора', 'Начислено', 'К выплате', 'Выручка СК', 'Прибыль'].join(SEP), ..._ecoData.map(emp => [ `"${emp.name.replace(/"/g, '""')}"`, `"${(emp.dept || '').replace(/"/g, '""')}"`, `"${(emp.catLabel || '').replace(/"/g, '""')}"`, emp.isDirectContract ? 'РЕСО' : 'Наш', Math.round(emp.gross), Math.round(emp.net), Math.round(emp.revenue), Math.round(emp.profit), ].join(SEP)), ]; const csv = '' + rows.join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Payroll_${yearMonth}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function _wireEcoListeners() { document.getElementById('eco-year-sel').addEventListener('change', _onEcoMonthChange); document.getElementById('eco-month-sel').addEventListener('change', _onEcoMonthChange); document.getElementById('eco-compare-sel').addEventListener('change', _onEcoMonthChange); document.getElementById('btn-eco-tariffs').addEventListener('click', _openEcoTariffsModal); document.getElementById('btn-eco-export').addEventListener('click', _exportEcoToCSV); document.getElementById('eco-tariffs-close').addEventListener('click', _closeEcoTariffsModal); document.getElementById('eco-tariffs-cancel').addEventListener('click', _closeEcoTariffsModal); document.getElementById('eco-tariffs-save').addEventListener('click', _saveEcoTariffs); document.getElementById('btn-eco-tariff-add').addEventListener('click', _addEcoTariffRow); document.getElementById('modal-eco-tariffs').addEventListener('click', e => { if (e.target === e.currentTarget) _closeEcoTariffsModal(); }); document.getElementById('eco-master-body').addEventListener('click', e => { const row = e.target.closest('tr[data-eco-id]'); if (row) _ecoSelect(parseInt(row.dataset.ecoId, 10)); }); document.getElementById('eco-tariff-version-sel').addEventListener('change', e => { _ecoTariffEditKey = e.target.value; _renderEcoTariffsTable(); _renderEcoVersionHistory(); }); document.getElementById('eco-indexation-toggle').addEventListener('click', () => { const body = document.getElementById('eco-indexation-body'); const arrow = document.getElementById('eco-indexation-arrow'); const open = body.style.display !== 'none'; body.style.display = open ? 'none' : ''; arrow.textContent = open ? '▾' : '▴'; }); document.getElementById('btn-eco-new-version').addEventListener('click', () => { const latestKey = Object.keys(_TARIFF_HISTORY).sort().reverse()[0]; const ly = parseInt(latestKey.split('-')[0], 10); document.getElementById('eco-new-version-month').value = `${ly + 1}-03`; document.getElementById('eco-new-version-coeff').value = '1.05'; document.getElementById('eco-new-version-form').style.display = ''; document.getElementById('btn-eco-new-version').style.display = 'none'; }); document.getElementById('btn-eco-create-version').addEventListener('click', _createEcoVersion); document.getElementById('btn-eco-cancel-version').addEventListener('click', () => { document.getElementById('eco-new-version-form').style.display = 'none'; document.getElementById('btn-eco-new-version').style.display = ''; }); document.getElementById('eco-version-history-list').addEventListener('click', e => { const btn = e.target.closest('.eco-version-load'); if (!btn) return; _ecoTariffEditKey = btn.dataset.key; document.getElementById('eco-tariff-version-sel').value = _ecoTariffEditKey; _renderEcoTariffsTable(); _renderEcoVersionHistory(); }); } // ── Surcharge Rules Manager ─────────────────────────────────────────────────── const _SC_FIELD_LABELS = { base_count: 'Кол-во услуг', expert_calculation: 'Расчёт эксперта', case_count: 'Кол-во дел', }; async function loadSurchargeRules() { const list = document.getElementById('sc-rules-list'); if (!list) return; list.innerHTML = '<p style="color:var(--color-text-muted);text-align:center;padding:24px;">Загрузка…</p>'; try { const r = await fetch('/api/economics/surcharge-rules'); if (!r.ok) throw new Error(`HTTP ${r.status}`); _scRules = await r.json(); _renderSurchargeRules(_scRules); } catch (err) { list.innerHTML = `<p style="color:var(--color-danger);padding:20px;">${errBlock(err.message)}</p>`; } } function _renderSurchargeRules(rules) { const list = document.getElementById('sc-rules-list'); if (!list) return; list.innerHTML = ''; if (!rules.length) { list.innerHTML = '<p class="sc-empty-state" id="sc-empty-state">Правила не настроены. Нажмите «+ Добавить правило».</p>'; return; } rules.forEach(r => list.appendChild(_renderSurchargeRuleCard(r))); } function _renderSurchargeRuleCard(rule) { const card = document.createElement('div'); card.className = `sc-rule-card${rule.is_active ? '' : ' sc-inactive'}`; card.dataset.id = rule.id; const filters = []; if (rule.region) filters.push(`<span class="sc-badge sc-badge-filter">Регион: ${esc(rule.region)}</span>`); if (rule.service_type) filters.push(`<span class="sc-badge sc-badge-filter">Услуга: ${esc(rule.service_type)}</span>`); if (rule.risk) filters.push(`<span class="sc-badge sc-badge-filter">Риск: ${esc(rule.risk)}</span>`); const fieldLabel = _SC_FIELD_LABELS[rule.condition_field] || rule.condition_field; const condText = `ЕСЛИ ${fieldLabel} ${rule.condition_operator} ${rule.condition_value}`; const resultText = `+${(rule.surcharge_amount || 0).toLocaleString('ru-RU')} ₽`; const filterHtml = filters.length ? filters.join('') + ' <span class="sc-arrow">→</span>' : ''; card.innerHTML = ` <div class="sc-rule-info"> <strong class="sc-rule-name">${esc(rule.name)}</strong> <div class="sc-rule-badges"> ${filterHtml} <span class="sc-badge sc-badge-condition">${condText}</span> <span class="sc-arrow">→</span> <span class="sc-badge sc-badge-result">${resultText}</span> </div> </div> <div class="sc-rule-actions"> <button class="sc-toggle-btn${rule.is_active ? ' sc-active' : ''}" title="${rule.is_active ? 'Выключить' : 'Включить'}" data-id="${rule.id}"> ${rule.is_active ? '✓' : '○'} </button> <button class="sc-delete-btn" title="Удалить правило" data-id="${rule.id}">🗑</button> </div>`; card.querySelector('.sc-toggle-btn').addEventListener('click', () => _toggleSurchargeRule(rule.id)); card.querySelector('.sc-delete-btn').addEventListener('click', () => _deleteSurchargeRule(rule.id)); return card; } async function _saveSurchargeRule() { const name = document.getElementById('sc-rule-name').value.trim(); if (!name) { alert('Введите название правила.'); return; } const body = { name, region: document.getElementById('sc-rule-region').value.trim() || null, service_type: document.getElementById('sc-rule-service').value.trim() || null, risk: document.getElementById('sc-rule-risk').value || null, condition_field: document.getElementById('sc-cond-field').value, condition_operator: document.getElementById('sc-cond-op').value, condition_value: parseFloat(document.getElementById('sc-cond-val').value) || 0, surcharge_amount: parseFloat(document.getElementById('sc-amount').value) || 0, }; const saveBtn = document.getElementById('sc-modal-save'); saveBtn.disabled = true; try { const r = await fetch('/api/economics/surcharge-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const created = await r.json(); _closeSurchargeModal(); _scRules.push(created); _renderSurchargeRules(_scRules); } catch (err) { alert(`Ошибка при сохранении: ${err.message}`); } finally { saveBtn.disabled = false; } } async function _toggleSurchargeRule(id) { try { const r = await fetch(`/api/economics/surcharge-rules/${id}/toggle`, { method: 'PATCH' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const data = await r.json(); _scRules = _scRules.map(rule => rule.id === id ? { ...rule, is_active: data.is_active } : rule ); _renderSurchargeRules(_scRules); } catch (err) { alert(`Ошибка: ${err.message}`); } } async function _deleteSurchargeRule(id) { const rule = _scRules.find(r => r.id === id); if (!confirm(`Удалить правило «${rule ? rule.name : id}»?`)) return; try { const r = await fetch(`/api/economics/surcharge-rules/${id}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); _scRules = _scRules.filter(r => r.id !== id); _renderSurchargeRules(_scRules); } catch (err) { alert(`Ошибка: ${err.message}`); } } function _openSurchargeModal() { ['sc-rule-id', 'sc-rule-name', 'sc-rule-region', 'sc-rule-service'].forEach(id => { document.getElementById(id).value = ''; }); document.getElementById('sc-rule-risk').value = ''; document.getElementById('sc-cond-field').value = 'base_count'; document.getElementById('sc-cond-op').value = '>'; document.getElementById('sc-cond-val').value = '0'; document.getElementById('sc-amount').value = '0'; document.getElementById('modal-surcharge-rule').classList.remove('hidden'); setTimeout(() => document.getElementById('sc-rule-name').focus(), 50); } function _closeSurchargeModal() { document.getElementById('modal-surcharge-rule').classList.add('hidden'); } // ── Manual Surcharges ───────────────────────────────────────────────────────── const _MS_MONTH_NAMES = [ 'Январь','Февраль','Март','Апрель','Май','Июнь', 'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь', ]; function _initMsPeriodSels() { const yearSel = document.getElementById('ms-year-sel'); const monthSel = document.getElementById('ms-month-sel'); if (!yearSel || !monthSel) return; const now = new Date(); const cy = now.getFullYear(); yearSel.innerHTML = ''; for (let y = cy - 2; y <= cy + 1; y++) { const opt = document.createElement('option'); opt.value = y; opt.textContent = y; if (y === cy) opt.selected = true; yearSel.appendChild(opt); } monthSel.innerHTML = _MS_MONTH_NAMES.map((name, i) => `<option value="${i + 1}"${i + 1 === now.getMonth() + 1 ? ' selected' : ''}>${name}</option>` ).join(''); } async function loadManualSurcharges() { const year = parseInt(document.getElementById('ms-year-sel').value, 10); const month = parseInt(document.getElementById('ms-month-sel').value, 10); const tbody = document.getElementById('ms-table-body'); if (!tbody || isNaN(year) || isNaN(month)) return; tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--color-text-muted);padding:20px;">Загрузка…</td></tr>'; try { const r = await fetch(`/api/economics/manual-surcharges?year=${year}&month=${month}`); if (!r.ok) throw new Error(`HTTP ${r.status}`); _msRecords = await r.json(); _renderManualSurcharges(_msRecords); } catch (err) { tbody.innerHTML = `<tr><td colspan="9">${errBlock(err.message)}</td></tr>`; } } function _renderManualSurcharges(records) { const tbody = document.getElementById('ms-table-body'); if (!tbody) return; if (!records.length) { tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--color-text-muted);padding:28px;">Записей за выбранный период нет.</td></tr>'; return; } tbody.innerHTML = records.map(r => { const typeClass = r.operation_type === 'Доплата' ? 'ms-type-surcharge' : 'ms-type-deduct'; const hrs = (r.hours || 0).toLocaleString('ru-RU', { maximumFractionDigits: 1 }); const amt = (r.total_amount || 0).toLocaleString('ru-RU', { maximumFractionDigits: 2 }); return `<tr> <td>${esc(r.expert_name || '—')}</td> <td>${esc(r.department || '—')}</td> <td>${esc(r.case_number || '—')}</td> <td>${esc(r.service_name || '—')}</td> <td class="ms-num">${hrs}</td> <td class="ms-num">${amt}</td> <td><span class="${typeClass}">${esc(r.operation_type || '—')}</span></td> <td>${esc(r.note || '—')}</td> <td style="text-align:center;"> <button class="btn-clear ms-del-btn" data-id="${r.id}" title="Удалить">🗑</button> </td> </tr>`; }).join(''); tbody.querySelectorAll('.ms-del-btn').forEach(btn => { btn.addEventListener('click', () => _deleteManualSurcharge(parseInt(btn.dataset.id, 10))); }); } async function _loadMsReferenceData() { if (!_msEmployees.length) { try { const r = await fetch('/api/employees'); if (r.ok) _msEmployees = (await r.json()).filter(e => !e.is_fired); } catch (_) { /* silent */ } } if (!_msTariffs.length) { try { const r = await fetch('/api/economics/service-tariffs'); if (r.ok) _msTariffs = (await r.json()).filter(t => t.name); } catch (_) { /* silent */ } } } async function _openManualSurchargeModal() { await _loadMsReferenceData(); // Reset form ['ms-rec-id', 'ms-case', 'ms-dept', 'ms-base-cost', 'ms-hourly-rate', 'ms-note'] .forEach(id => { document.getElementById(id).value = ''; }); document.getElementById('ms-hours').value = '0'; document.getElementById('ms-total').value = ''; document.getElementById('ms-total').classList.remove('ms-negative'); document.getElementById('ms-op-type').value = 'Доплата'; const hint = document.getElementById('ms-total-hint'); if (hint) hint.style.display = 'none'; // Populate expert select const expertSel = document.getElementById('ms-expert'); expertSel.innerHTML = '<option value="">— выберите эксперта —</option>' + _msEmployees.map(e => `<option value="${esc(e.name)}" data-dept="${esc(e.department || '')}">${esc(e.name)}${e.department ? ' (' + esc(e.department) + ')' : ''}</option>` ).join(''); // Populate service select const svcSel = document.getElementById('ms-service'); svcSel.innerHTML = '<option value="">— выберите услугу —</option>' + _msTariffs.map(t => `<option value="${esc(t.name)}" data-base="${t.base_cost}" data-rate="${t.hourly_rate}">${esc(t.name)}</option>` ).join(''); document.getElementById('modal-manual-surcharge').classList.remove('hidden'); setTimeout(() => expertSel.focus(), 50); } function _closeManualSurchargeModal() { document.getElementById('modal-manual-surcharge').classList.add('hidden'); } function _recalcManualTotal() { const rate = parseFloat(document.getElementById('ms-hourly-rate').value) || 0; const base = parseFloat(document.getElementById('ms-base-cost').value) || 0; const hours = parseFloat(document.getElementById('ms-hours').value) || 0; const total = hours * rate - base; const totalFld = document.getElementById('ms-total'); const hint = document.getElementById('ms-total-hint'); totalFld.value = (hours > 0 || base > 0) ? total.toFixed(2) : ''; totalFld.classList.toggle('ms-negative', total < 0 && (hours > 0 || base > 0)); if (hint) { if (total < 0 && hours > 0) { hint.textContent = '⚠ Результат отрицательный — рекомендуется тип «Взаимовычет»'; hint.style.display = ''; const opType = document.getElementById('ms-op-type'); if (opType && opType.value === 'Доплата') opType.value = 'Взаимовычет'; } else { hint.style.display = 'none'; } } } async function _saveManualSurcharge() { const expertName = document.getElementById('ms-expert').value.trim(); if (!expertName) { alert('Выберите эксперта.'); return; } const year = parseInt(document.getElementById('ms-year-sel').value, 10); const month = parseInt(document.getElementById('ms-month-sel').value, 10); const body = { expert_name: expertName, department: document.getElementById('ms-dept').value.trim() || null, case_number: document.getElementById('ms-case').value.trim() || null, service_name: document.getElementById('ms-service').value || null, base_cost: parseFloat(document.getElementById('ms-base-cost').value) || 0, hourly_rate: parseFloat(document.getElementById('ms-hourly-rate').value) || 0, hours: parseFloat(document.getElementById('ms-hours').value) || 0, total_amount: parseFloat(document.getElementById('ms-total').value) || 0, operation_type: document.getElementById('ms-op-type').value, note: document.getElementById('ms-note').value.trim() || null, period_date: `${year}-${String(month).padStart(2, '0')}-01`, }; const saveBtn = document.getElementById('ms-modal-save'); saveBtn.disabled = true; try { const r = await fetch('/api/economics/manual-surcharges', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); _closeManualSurchargeModal(); await loadManualSurcharges(); } catch (err) { alert(`Ошибка при сохранении: ${err.message}`); } finally { saveBtn.disabled = false; } } async function _deleteManualSurcharge(id) { const rec = _msRecords.find(r => r.id === id); const label = rec ? `${rec.expert_name}${rec.service_name ? ' / ' + rec.service_name : ''}` : String(id); if (!confirm(`Удалить запись «${label}»?`)) return; try { const r = await fetch(`/api/economics/manual-surcharges/${id}`, { method: 'DELETE' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); _msRecords = _msRecords.filter(r => r.id !== id); _renderManualSurcharges(_msRecords); } catch (err) { alert(`Ошибка: ${err.message}`); } } function _wireManualSurchargeListeners() { if (_msListenersWired) return; _msListenersWired = true; _initMsPeriodSels(); document.getElementById('ms-year-sel').addEventListener('change', loadManualSurcharges); document.getElementById('ms-month-sel').addEventListener('change', loadManualSurcharges); document.getElementById('btn-ms-add').addEventListener('click', _openManualSurchargeModal); document.getElementById('ms-modal-close').addEventListener('click', _closeManualSurchargeModal); document.getElementById('ms-modal-cancel').addEventListener('click', _closeManualSurchargeModal); document.getElementById('ms-modal-save').addEventListener('click', _saveManualSurcharge); document.getElementById('modal-manual-surcharge').addEventListener('click', e => { if (e.target === e.currentTarget) _closeManualSurchargeModal(); }); // Expert → auto-fill department document.getElementById('ms-expert').addEventListener('change', () => { const opt = document.getElementById('ms-expert').selectedOptions[0]; document.getElementById('ms-dept').value = opt?.dataset.dept || ''; }); // Service → auto-fill base cost + hourly rate, then recalc document.getElementById('ms-service').addEventListener('change', () => { const opt = document.getElementById('ms-service').selectedOptions[0]; document.getElementById('ms-base-cost').value = opt?.dataset.base || ''; document.getElementById('ms-hourly-rate').value = opt?.dataset.rate || ''; _recalcManualTotal(); }); // Hours → reactive recalc document.getElementById('ms-hours').addEventListener('input', _recalcManualTotal); } function _wireSurchargeListeners() { if (_scListenersWired) return; _scListenersWired = true; // Sub-nav switching document.querySelectorAll('.eco-subnav-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.eco-subnav-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const nav = btn.dataset.econav; document.getElementById('eco-section-calc').style.display = nav === 'calc' ? '' : 'none'; document.getElementById('eco-section-surcharges').style.display = nav === 'surcharges' ? '' : 'none'; document.getElementById('eco-section-manual').style.display = nav === 'manual' ? '' : 'none'; if (nav === 'surcharges') loadSurchargeRules(); if (nav === 'manual') loadManualSurcharges(); }); }); document.getElementById('btn-sc-add').addEventListener('click', _openSurchargeModal); document.getElementById('sc-modal-close').addEventListener('click', _closeSurchargeModal); document.getElementById('sc-modal-cancel').addEventListener('click', _closeSurchargeModal); document.getElementById('sc-modal-save').addEventListener('click', _saveSurchargeRule); document.getElementById('modal-surcharge-rule').addEventListener('click', e => { if (e.target === e.currentTarget) _closeSurchargeModal(); }); _wireManualSurchargeListeners(); } // ───────────────────────────────────────────────────────────────────────────── async function initEconomics() { const chip = document.getElementById('eco-chip'); if (!_ecoListenersWired) { _initEcoMonthSels(); _wireEcoListeners(); _wireSurchargeListeners(); _ecoListenersWired = true; } try { await _runEcoCalc(); tabLoaded.economics = true; } catch (err) { tabLoaded.economics = false; setChip(chip, 'error', err.message); document.getElementById('eco-master-body').innerHTML = `<tr><td colspan="3">${errBlock(err.message)}</td></tr>`; } } function _ecoSelect(id) { _ecoSelectedId = id; document.querySelectorAll('#eco-master-body tr').forEach(r => { r.classList.toggle('eco-row-active', parseInt(r.dataset.ecoId, 10) === id); }); const emp = _ecoData.find(e => e.id === id); if (!emp) return; document.getElementById('eco-detail-title').textContent = emp.name; document.getElementById('eco-detail-empty').style.display = 'none'; const content = document.getElementById('eco-detail-content'); content.style.display = ''; const catClass = emp.catKey ? `eco-cat-${emp.catKey}` : 'eco-cat-first'; const catLabel = emp.catLabel || 'Первая'; const versionTag = _ecoAppliedVersion ? `<span style="font-size:11px;color:var(--color-text-muted);">прайс: ${_fmtVersionKey(_ecoAppliedVersion)}</span>` : ''; const resoBadge = emp.isDirectContract ? `<span class="eco-badge-reso" style="margin-left:6px;">РЕСО</span>` : ''; let html = `<div class="eco-detail-meta"><span class="eco-cat-badge ${catClass}">${esc(catLabel)} категория</span>${resoBadge}${versionTag}</div>`; emp.groups.forEach(g => { html += `<div class="eco-group">`; html += `<div class="eco-group-title">${esc(g.title)}</div>`; g.items.forEach(item => { const unit = item.isKm ? 'км' : 'шт'; const displayName = item.name === 'NDISTANCE' ? 'Выезд (километраж)' : item.name; const taxSpan = item.taxPct > 0 ? `<span class="eco-pill eco-pill-dim">−${item.taxPct}%</span>` : ''; html += ` <div class="eco-formula-row"> <span class="eco-formula-name">${esc(displayName)}</span> <span class="eco-formula-dots"></span> <span class="eco-pill">${item.qty} ${unit}</span> <span class="eco-op">×</span> <span class="eco-pill">${item.rate.toLocaleString('ru-RU')} ₽</span> ${taxSpan} <span class="eco-op">=</span> <span class="eco-formula-result">${_ecoFmt(item.net)}</span> </div>`; }); html += '</div>'; }); const resoNotice = emp.isDirectContract ? `<div style="margin-top:8px;padding:6px 10px;background:var(--color-warning-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--color-warning-text);">⚠️ Прямой договор (не учитывается в общем ФОТ компании)</div>` : ''; html += ` <div class="eco-total"> <div style="display:flex;justify-content:space-between;align-items:baseline;gap:32px;padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid var(--color-border);"> <span style="font-size:11px;font-weight:700;letter-spacing:.7px;color:var(--color-text-muted);white-space:nowrap;">НАЧИСЛЕНО</span> <span style="font-size:15px;font-weight:600;color:var(--color-text-muted);white-space:nowrap;">${_ecoFmt(emp.gross)}</span> </div> <div style="display:flex;justify-content:space-between;align-items:baseline;gap:32px;"> <span class="eco-total-label">К ВЫПЛАТЕ</span> <span class="eco-total-amount">${_ecoFmt(emp.net)}</span> </div> ${resoNotice} </div>`; content.innerHTML = html; } // ── Bootstrap ───────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { switchTab('dashboard'); populateWlDeptSelect(); });