566 lines
18 KiB
JavaScript
566 lines
18 KiB
JavaScript
// Основное приложение
|
||
let currentFilter = 'all';
|
||
let currentDecreeType = 'all';
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initNavigation();
|
||
initUploadZones();
|
||
initFilters();
|
||
initForms();
|
||
renderAll();
|
||
|
||
document.getElementById('current-period').addEventListener('change', (e) => {
|
||
setCurrentPeriod(e.target.value);
|
||
renderAll();
|
||
});
|
||
});
|
||
|
||
function initNavigation() {
|
||
document.querySelectorAll('.nav button').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.nav button').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
|
||
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
||
document.getElementById(btn.dataset.section).classList.add('active');
|
||
|
||
renderAll();
|
||
});
|
||
});
|
||
}
|
||
|
||
function initUploadZones() {
|
||
const sapZone = document.getElementById('sap-upload');
|
||
const sapFile = document.getElementById('sap-file');
|
||
|
||
sapZone.addEventListener('click', () => sapFile.click());
|
||
sapFile.addEventListener('change', handleSapUpload);
|
||
|
||
sapZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
sapZone.classList.add('dragover');
|
||
});
|
||
|
||
sapZone.addEventListener('dragleave', () => {
|
||
sapZone.classList.remove('dragover');
|
||
});
|
||
|
||
sapZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
sapZone.classList.remove('dragover');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) {
|
||
processSapFile(files[0]);
|
||
}
|
||
});
|
||
|
||
const paymentZone = document.getElementById('payment-upload');
|
||
const paymentFile = document.getElementById('payment-file');
|
||
|
||
paymentZone.addEventListener('click', () => paymentFile.click());
|
||
paymentFile.addEventListener('change', handlePaymentUpload);
|
||
|
||
paymentZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
paymentZone.classList.add('dragover');
|
||
});
|
||
|
||
paymentZone.addEventListener('dragleave', () => {
|
||
paymentZone.classList.remove('dragover');
|
||
});
|
||
|
||
paymentZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
paymentZone.classList.remove('dragover');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) {
|
||
processPaymentFile(files[0]);
|
||
}
|
||
});
|
||
}
|
||
|
||
function initFilters() {
|
||
document.querySelectorAll('#employees .chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('#employees .chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
currentFilter = chip.dataset.filter;
|
||
renderEmployees();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('#decrees .chip').forEach(chip => {
|
||
chip.addEventListener('click', () => {
|
||
document.querySelectorAll('#decrees .chip').forEach(c => c.classList.remove('active'));
|
||
chip.classList.add('active');
|
||
currentDecreeType = chip.dataset.type;
|
||
renderDecrees();
|
||
});
|
||
});
|
||
|
||
document.getElementById('emp-search').addEventListener('input', renderEmployees);
|
||
document.getElementById('decree-search').addEventListener('input', renderDecrees);
|
||
}
|
||
|
||
function initForms() {
|
||
document.getElementById('decree-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
|
||
const decree = {
|
||
number: document.getElementById('decree-number').value.trim(),
|
||
date: document.getElementById('decree-date').value,
|
||
employeeId: parseInt(document.getElementById('decree-employee').value),
|
||
type: document.getElementById('decree-type').value,
|
||
amount: parseFloat(document.getElementById('decree-amount').value),
|
||
comment: document.getElementById('decree-comment').value.trim()
|
||
};
|
||
|
||
addDecree(decree);
|
||
closeModal('decree-modal');
|
||
document.getElementById('decree-form').reset();
|
||
renderAll();
|
||
showToast('Постановление добавлено', 'success');
|
||
});
|
||
|
||
document.getElementById('employee-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
|
||
const id = parseInt(document.getElementById('edit-emp-id').value);
|
||
const updates = {
|
||
tabNum: document.getElementById('edit-emp-tabnum').value.trim(),
|
||
name: document.getElementById('edit-emp-name').value.trim(),
|
||
iin: document.getElementById('edit-emp-iin').value.trim(),
|
||
salary: parseFloat(document.getElementById('edit-emp-salary').value)
|
||
};
|
||
|
||
updateEmployee(id, updates);
|
||
closeModal('employee-modal');
|
||
renderAll();
|
||
showToast('Сотрудник обновлён', 'success');
|
||
});
|
||
}
|
||
|
||
async function handleSapUpload(e) {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const resp = await fetch('/api/upload-sap', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await resp.json();
|
||
if (result.ok) {
|
||
document.getElementById('sap-preview').innerHTML =
|
||
`<div class="card"><h3>✅ Загружено сотрудников: ${result.imported}</h3><p>Всего в базе: ${result.total}</p></div>`;
|
||
showToast(`Загружено ${result.imported} сотрудников`, 'success');
|
||
renderAll();
|
||
} else {
|
||
showToast('Ошибка: ' + result.error, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка загрузки: ' + err.message, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handlePaymentUpload(e) {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('period', getCurrentPeriod());
|
||
|
||
const resp = await fetch('/api/upload-payment', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await resp.json();
|
||
if (result.ok) {
|
||
document.getElementById('payment-preview').innerHTML =
|
||
`<div class="card"><h3>✅ Обработано платёжек: ${result.imported}</h3></div>`;
|
||
showToast(`Обработано ${result.imported} выплат`, 'success');
|
||
renderAll();
|
||
} else {
|
||
showToast('Ошибка: ' + result.error, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка загрузки: ' + err.message, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
function parseExcel(arrayBuffer) {
|
||
const data = new Uint8Array(arrayBuffer);
|
||
let str = '';
|
||
for (let i = 0; i < data.length; i++) {
|
||
str += String.fromCharCode(data[i]);
|
||
}
|
||
|
||
const rows = str.split(/\r?\n/).filter(r => r.trim());
|
||
if (rows.length < 2) return [];
|
||
|
||
const headers = rows[0].split(/[\t;]/).map(h => h.trim().replace(/^"/, '').replace(/"$/, ''));
|
||
const result = [];
|
||
|
||
for (let i = 1; i < rows.length; i++) {
|
||
const values = rows[i].split(/[\t;]/).map(v => v.trim().replace(/^"/, '').replace(/"$/, ''));
|
||
const row = {};
|
||
headers.forEach((h, idx) => {
|
||
row[h] = values[idx] || '';
|
||
});
|
||
result.push(row);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function renderAll() {
|
||
renderDashboard();
|
||
renderEmployees();
|
||
renderDecrees();
|
||
renderAnalytics();
|
||
renderPayments();
|
||
renderReports();
|
||
populateEmployeeSelect();
|
||
}
|
||
|
||
function renderDashboard() {
|
||
const stats = getStats();
|
||
document.getElementById('stat-employees').textContent = stats.employees;
|
||
document.getElementById('stat-decrees').textContent = stats.activeDecrees;
|
||
document.getElementById('stat-total-debt').textContent = formatMoney(stats.totalDebt);
|
||
document.getElementById('stat-paid').textContent = formatMoney(stats.paidThisMonth);
|
||
|
||
const ops = getRecentOperations(5);
|
||
const opsHtml = ops.length ? ops.map(o =>
|
||
`<div style="padding:8px 0;border-bottom:1px solid #eee;font-size:13px">
|
||
<span style="color:var(--gray-500)">${new Date(o.timestamp).toLocaleDateString('ru-RU')}</span>:
|
||
${o.description}
|
||
</div>`
|
||
).join('') : '<p style="color:var(--gray-500)">Операций пока нет</p>';
|
||
document.getElementById('recent-ops').innerHTML = opsHtml;
|
||
|
||
const attention = db.decrees.filter(d => {
|
||
const remaining = d.amount - (d.paidAmount || 0);
|
||
return d.status === 'active' && remaining > 0 && remaining < 10000;
|
||
});
|
||
const attentionHtml = attention.length ? attention.map(d =>
|
||
`<div style="padding:8px 0;border-bottom:1px solid #eee;font-size:13px">
|
||
⚠️ ${d.number} — остаток ${formatMoney(d.amount - d.paidAmount)}
|
||
</div>`
|
||
).join('') : '<p style="color:var(--gray-500)">Нет проблемных записей</p>';
|
||
document.getElementById('attention-list').innerHTML = attentionHtml;
|
||
}
|
||
|
||
function renderEmployees() {
|
||
const search = document.getElementById('emp-search').value.toLowerCase();
|
||
let employees = getEmployees();
|
||
|
||
if (currentFilter === 'active') {
|
||
employees = employees.filter(e => getDecreesForEmployee(e.id).length > 0);
|
||
} else if (currentFilter === 'no-decree') {
|
||
employees = employees.filter(e => getDecreesForEmployee(e.id).length === 0);
|
||
}
|
||
|
||
if (search) {
|
||
employees = employees.filter(e =>
|
||
e.name.toLowerCase().includes(search) ||
|
||
e.iin.includes(search) ||
|
||
e.tabNum.toLowerCase().includes(search)
|
||
);
|
||
}
|
||
|
||
const tbody = document.querySelector('#employees-table tbody');
|
||
tbody.innerHTML = employees.map(e => {
|
||
const decrees = getDecreesForEmployee(e.id);
|
||
const paidThisMonth = decrees.reduce((sum, d) => sum + (d.paidAmount || 0), 0);
|
||
|
||
return `
|
||
<tr>
|
||
<td>${e.tabNum}</td>
|
||
<td>${e.name}</td>
|
||
<td>${e.iin}</td>
|
||
<td>${formatMoney(e.salary)}</td>
|
||
<td>${decrees.length}</td>
|
||
<td>${formatMoney(paidThisMonth)}</td>
|
||
<td>
|
||
<button class="btn btn-secondary" onclick="editEmployee(${e.id})" style="padding:6px 12px;font-size:12px">✏️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderDecrees() {
|
||
const search = document.getElementById('decree-search').value.toLowerCase();
|
||
let decrees = getDecrees(currentDecreeType);
|
||
|
||
if (search) {
|
||
decrees = decrees.filter(d => {
|
||
const emp = db.employees.find(e => e.id === d.employeeId);
|
||
return d.number.toLowerCase().includes(search) ||
|
||
(emp && emp.name.toLowerCase().includes(search));
|
||
});
|
||
}
|
||
|
||
const tbody = document.querySelector('#decrees-table tbody');
|
||
tbody.innerHTML = decrees.map(d => {
|
||
const emp = db.employees.find(e => e.id === d.employeeId);
|
||
const remaining = d.amount - (d.paidAmount || 0);
|
||
const percent = d.amount > 0 ? ((d.paidAmount || 0) / d.amount * 100).toFixed(1) : 0;
|
||
|
||
return `
|
||
<tr>
|
||
<td>${d.number}</td>
|
||
<td>${new Date(d.date).toLocaleDateString('ru-RU')}</td>
|
||
<td>${emp ? emp.name : '❌ Не найден'}</td>
|
||
<td>
|
||
<span class="badge ${d.type === 'alimony' ? 'badge-ali' : 'badge-other'}">
|
||
${d.type === 'alimony' ? 'Алименты' : 'Штрафы'}
|
||
</span>
|
||
</td>
|
||
<td>${formatMoney(d.amount)}</td>
|
||
<td>${formatMoney(d.paidAmount || 0)}</td>
|
||
<td>${percent}%</td>
|
||
<td>
|
||
<span class="badge ${d.status === 'closed' ? 'badge-closed' : 'badge-active'}">
|
||
${d.status === 'closed' ? 'Закрыто' : 'Активно'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button class="btn btn-danger" onclick="deleteDecreeItem(${d.id})" style="padding:6px 12px;font-size:12px">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderAnalytics() {
|
||
const employees = getEmployees();
|
||
const tbody = document.querySelector('#analytics-table tbody');
|
||
|
||
const analytics = employees.map(e => calculateDeductionsForEmployee(e)).filter(a => a);
|
||
|
||
tbody.innerHTML = analytics.map(a => {
|
||
const alimonyCount = a.allocation.filter(x => x.decree.type === 'alimony').length;
|
||
const otherCount = a.allocation.filter(x => x.decree.type === 'other').length;
|
||
|
||
return `
|
||
<tr>
|
||
<td>${a.employee.name}</td>
|
||
<td>${formatMoney(a.employee.salary)}</td>
|
||
<td>${formatMoney(a.maxDeduction)}</td>
|
||
<td>${a.allocation.length}</td>
|
||
<td>${alimonyCount}</td>
|
||
<td>${otherCount}</td>
|
||
<td>${formatMoney(a.totalAllocated)}</td>
|
||
<td>${formatMoney(a.remaining)}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderPayments() {
|
||
const payments = getPayments();
|
||
const tbody = document.querySelector('#payments-table tbody');
|
||
|
||
tbody.innerHTML = payments.map(p => {
|
||
const decree = db.decrees.find(d => d.id === p.decreeId);
|
||
return `
|
||
<tr>
|
||
<td>${new Date(p.date).toLocaleDateString('ru-RU')}</td>
|
||
<td>${p.decreeNumber}</td>
|
||
<td>${decree ? (db.employees.find(e => e.id === decree.employeeId)?.name || '❌') : '❌'}</td>
|
||
<td>
|
||
<span class="badge ${decree && decree.type === 'alimony' ? 'badge-ali' : 'badge-other'}">
|
||
${decree && decree.type === 'alimony' ? 'Алименты' : 'Штрафы'}
|
||
</span>
|
||
</td>
|
||
<td>${formatMoney(p.amount)}</td>
|
||
<td><span class="badge badge-closed">✅</span></td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderReports() {
|
||
const quarter = document.getElementById('report-quarter').value;
|
||
const [q, year] = quarter.split('-');
|
||
const quarterNum = parseInt(q.replace('Q', ''));
|
||
const startMonth = (quarterNum - 1) * 3 + 1;
|
||
|
||
const tbody = document.querySelector('#report-table tbody');
|
||
const decrees = getDecrees();
|
||
|
||
tbody.innerHTML = decrees.map(d => {
|
||
const emp = db.employees.find(e => e.id === d.employeeId);
|
||
return `
|
||
<tr>
|
||
<td>${quarter}</td>
|
||
<td>${d.number}</td>
|
||
<td>${emp ? emp.name : '❌'}</td>
|
||
<td><span class="badge ${d.type === 'alimony' ? 'badge-ali' : 'badge-other'}">${d.type === 'alimony' ? 'Алименты' : 'Штрафы'}</span></td>
|
||
<td>${formatMoney(d.amount)}</td>
|
||
<td>${formatMoney(d.paidAmount || 0)}</td>
|
||
<td>${formatMoney(d.amount - (d.paidAmount || 0))}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
const stats = getStats();
|
||
document.getElementById('report-stats').innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:12px">
|
||
<div style="padding:16px;background:var(--gray-100);border-radius:8px">
|
||
<div style="font-size:24px;font-weight:700">${stats.activeDecrees}</div>
|
||
<div style="font-size:13px;color:var(--gray-500)">Активных постановлений</div>
|
||
</div>
|
||
<div style="padding:16px;background:var(--gray-100);border-radius:8px">
|
||
<div style="font-size:24px;font-weight:700">${stats.closedDecrees}</div>
|
||
<div style="font-size:13px;color:var(--gray-500)">Закрыто за период</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function populateEmployeeSelect() {
|
||
const select = document.getElementById('decree-employee');
|
||
const employees = getEmployees().sort((a, b) => a.name.localeCompare(b.name));
|
||
|
||
select.innerHTML = '<option value="">Выберите сотрудника</option>' +
|
||
employees.map(e => `<option value="${e.id}">${e.tabNum} — ${e.name}</option>`).join('');
|
||
}
|
||
|
||
function calculateDeductions() {
|
||
const employees = getEmployees();
|
||
let total = 0;
|
||
|
||
employees.forEach(e => {
|
||
const result = calculateDeductionsForEmployee(e);
|
||
if (result) {
|
||
result.allocation.forEach(a => {
|
||
const remaining = a.decree.amount - (a.decree.paidAmount || 0);
|
||
if (remaining > 0) {
|
||
a.decree.paidAmount = (a.decree.paidAmount || 0) + a.allocated;
|
||
if (a.decree.paidAmount >= a.decree.amount) {
|
||
a.decree.status = 'closed';
|
||
}
|
||
total += a.allocated;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
saveData();
|
||
renderAll();
|
||
showToast(`Рассчитано удержаний на сумму ${formatMoney(total)}`, 'success');
|
||
}
|
||
|
||
async function exportBankExcel() {
|
||
try {
|
||
const resp = await fetch('/api/export-bank', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ period: getCurrentPeriod() })
|
||
});
|
||
|
||
const result = await resp.json();
|
||
if (result.ok) {
|
||
window.location.href = '/api/download/' + encodeURIComponent(result.filename);
|
||
showToast(`Файл для банка готов: ${result.count} записей`, 'success');
|
||
} else {
|
||
showToast('Ошибка: ' + result.error, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка экспорта: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function generateReport() {
|
||
renderReports();
|
||
showToast('Отчёт сформирован', 'success');
|
||
}
|
||
|
||
async function exportReportExcel() {
|
||
const quarter = document.getElementById('report-quarter').value;
|
||
|
||
try {
|
||
const resp = await fetch('/api/export-report', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ quarter })
|
||
});
|
||
|
||
const result = await resp.json();
|
||
if (result.ok) {
|
||
window.location.href = '/api/download/' + encodeURIComponent(result.filename);
|
||
showToast(`Отчёт готов: ${result.count} записей`, 'success');
|
||
} else {
|
||
showToast('Ошибка: ' + result.error, 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('Ошибка экспорта: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function editEmployee(id) {
|
||
const emp = db.employees.find(e => e.id === id);
|
||
if (emp) {
|
||
document.getElementById('edit-emp-id').value = emp.id;
|
||
document.getElementById('edit-emp-tabnum').value = emp.tabNum;
|
||
document.getElementById('edit-emp-name').value = emp.name;
|
||
document.getElementById('edit-emp-iin').value = emp.iin;
|
||
document.getElementById('edit-emp-salary').value = emp.salary;
|
||
openModal('employee-modal');
|
||
}
|
||
}
|
||
|
||
function deleteDecreeItem(id) {
|
||
if (confirm('Удалить это постановление?')) {
|
||
deleteDecree(id);
|
||
renderAll();
|
||
showToast('Постановление удалено', 'success');
|
||
}
|
||
}
|
||
|
||
function openModal(id) {
|
||
document.getElementById(id).classList.add('open');
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
}
|
||
|
||
function formatMoney(amount) {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: 'KZT',
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 2
|
||
}).format(amount || 0);
|
||
}
|
||
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.getElementById('toast');
|
||
toast.textContent = message;
|
||
toast.className = 'toast show ' + type;
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
}, 3000);
|
||
}
|
||
|
||
window.calculateDeductions = calculateDeductions;
|
||
window.exportBankExcel = exportBankExcel;
|
||
window.generateReport = generateReport;
|
||
window.exportReportExcel = exportReportExcel;
|
||
window.editEmployee = editEmployee;
|
||
window.deleteDecreeItem = deleteDecreeItem;
|
||
window.openModal = openModal;
|
||
window.closeModal = closeModal;
|