WFM: загрузка Excel + расчёт по месяцам
This commit is contained in:
parent
c4b301c5e7
commit
132d02c03b
55
index.html
55
index.html
@ -23,6 +23,18 @@
|
||||
.total-block { background: var(--kt-ai-primary); color: white; padding: var(--kt-ai-space-6); border-radius: var(--kt-ai-radius-3xl); margin-top: var(--kt-ai-space-6); }
|
||||
.total-block .result-value { color: white; }
|
||||
.total-block .result-label { color: rgba(255,255,255,0.8); }
|
||||
.upload-area { border: 2px dashed var(--kt-ai-border); border-radius: var(--kt-ai-radius-3xl); padding: var(--kt-ai-space-8); text-align: center; cursor: pointer; transition: all 0.2s; }
|
||||
.upload-area:hover { border-color: var(--kt-ai-primary); background: var(--kt-ai-bg-soft); }
|
||||
.upload-area.dragover { border-color: var(--kt-ai-primary); background: var(--kt-ai-primary); color: white; }
|
||||
.monthly-table { width: 100%; border-collapse: collapse; margin-top: var(--kt-ai-space-4); }
|
||||
.monthly-table th, .monthly-table td { padding: var(--kt-ai-space-3); text-align: right; border-bottom: 1px solid var(--kt-ai-border); }
|
||||
.monthly-table th { text-align: left; color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium); }
|
||||
.monthly-table tr:hover { background: var(--kt-ai-bg-soft); }
|
||||
.tabs { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-4); }
|
||||
.tab { padding: var(--kt-ai-space-2) var(--kt-ai-space-4); border-radius: var(--kt-ai-radius-lg); cursor: pointer; color: var(--kt-ai-fg-muted); }
|
||||
.tab.active { background: var(--kt-ai-primary); color: white; }
|
||||
.mode-switch { display: none; }
|
||||
.mode-switch.active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -41,20 +53,27 @@
|
||||
<section class="kt-ai-hero" data-align="center">
|
||||
<div class="kt-ai-wrap">
|
||||
<h1>Расчёт численности операторов Контакт-центра</h1>
|
||||
<p class="kt-ai-hero-sub">Профессиональный инструмент WFM на основе формул Эрланга. Рассчитайте требуемое количество агентов для входящих звонков, исходящих, чатов и тикетов.</p>
|
||||
<p class="kt-ai-hero-sub">Профессиональный инструмент WFM на основе формул Эрланга. Загрузите Excel с прогнозом или введите данные вручную.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="kt-ai-section">
|
||||
<div class="kt-ai-wrap">
|
||||
<div class="kt-ai-section-head">
|
||||
<h2>Параметры расчёта</h2>
|
||||
<p>Введите данные по каждому каналу связи</p>
|
||||
<h2>Режим расчёта</h2>
|
||||
<p>Выберите способ ввода данных</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs" style="margin-top:var(--kt-ai-space-4);justify-content:center">
|
||||
<div class="tab active" data-mode="manual"><svg class="kt-icon" style="vertical-align:middle"><use href="#pencil"></use></svg> Вручную</div>
|
||||
<div class="tab" data-mode="upload"><svg class="kt-icon" style="vertical-align:middle"><use href="#upload"></use></svg> Загрузить Excel</div>
|
||||
<div class="tab" data-mode="monthly"><svg class="kt-icon" style="vertical-align:middle"><use href="#chart"></use></svg> По месяцам</div>
|
||||
</div>
|
||||
|
||||
<!-- Ручной ввод -->
|
||||
<div id="manualMode" class="mode-switch active">
|
||||
<form id="wfmForm" style="margin-top:var(--kt-ai-space-6)">
|
||||
<div class="wfm-grid">
|
||||
<!-- Входящие звонки -->
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)"><svg class="kt-icon" aria-hidden="true"><use href="#megaphone"></use></svg> Входящие звонки</h3>
|
||||
<div class="kt-ai-field">
|
||||
@ -79,7 +98,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Исходящие звонки -->
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)"><svg class="kt-icon" aria-hidden="true"><use href="#megaphone"></use></svg> Исходящие звонки</h3>
|
||||
<div class="kt-ai-field">
|
||||
@ -92,7 +110,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Неголосовые тикеты -->
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)"><svg class="kt-icon" aria-hidden="true"><use href="#mail"></use></svg> Неголосовые тикеты</h3>
|
||||
<div class="kt-ai-field">
|
||||
@ -105,7 +122,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Чаты -->
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)"><svg class="kt-icon" aria-hidden="true"><use href="#chat"></use></svg> Чаты</h3>
|
||||
<div class="kt-ai-field">
|
||||
@ -122,7 +138,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Общие параметры -->
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)"><svg class="kt-icon" aria-hidden="true"><use href="#settings"></use></svg> Общие параметры</h3>
|
||||
<div class="kt-ai-field">
|
||||
@ -149,6 +164,30 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Загрузка Excel -->
|
||||
<div id="uploadMode" class="mode-switch">
|
||||
<div class="wfm-card" style="max-width:600px;margin:var(--kt-ai-space-6) auto">
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<svg class="kt-icon kt-icon-lg" style="margin-bottom:var(--kt-ai-space-4)" aria-hidden="true"><use href="#upload"></use></svg>
|
||||
<h3>Загрузите Excel файл</h3>
|
||||
<p style="color:var(--kt-ai-fg-muted);margin:var(--kt-ai-space-2) 0">Перетащите файл или нажмите для выбора</p>
|
||||
<p style="font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Формат: как в примере "Расчет численности ЦОО 2026 v1.xlsx"</p>
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display:none">
|
||||
</div>
|
||||
<div id="uploadStatus" style="margin-top:var(--kt-ai-space-4);text-align:center"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- По месяцам -->
|
||||
<div id="monthlyMode" class="mode-switch">
|
||||
<div id="monthlyResults" style="margin-top:var(--kt-ai-space-6)">
|
||||
<div class="wfm-card">
|
||||
<h3>Загрузите Excel файл во вкладке "Загрузить Excel" для расчёта по месяцам</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="results" class="kt-ai-section" data-divider="true" style="display:none">
|
||||
|
||||
178
package-lock.json
generated
178
package-lock.json
generated
@ -4,7 +4,183 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wfm-calculator"
|
||||
"name": "wfm-calculator",
|
||||
"dependencies": {
|
||||
"formidable": "^3.5.1",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,5 +4,9 @@
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"xlsx": "^0.18.5",
|
||||
"formidable": "^3.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
165
script.js
165
script.js
@ -2,6 +2,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('wfmForm');
|
||||
const resultsSection = document.getElementById('results');
|
||||
const historyList = document.getElementById('historyList');
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const uploadStatus = document.getElementById('uploadStatus');
|
||||
const monthlyResults = document.getElementById('monthlyResults');
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const modes = {
|
||||
manual: document.getElementById('manualMode'),
|
||||
upload: document.getElementById('uploadMode'),
|
||||
monthly: document.getElementById('monthlyMode')
|
||||
};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', function() {
|
||||
const mode = this.dataset.mode;
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
Object.keys(modes).forEach(k => modes[k].classList.remove('active'));
|
||||
modes[mode].classList.add('active');
|
||||
if (mode === 'monthly') {
|
||||
loadMonthlyResults();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
@ -32,6 +55,61 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFile(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFile(file) {
|
||||
uploadStatus.innerHTML = '<div class="kt-ai-card" style="padding:var(--kt-ai-space-4)">Загрузка файла...</div>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.ok) {
|
||||
uploadStatus.innerHTML = '<div class="kt-ai-card" style="padding:var(--kt-ai-space-4);background:var(--kt-ai-bg-soft)">✅ Файл загружен! Найдено месяцев: ' + result.months.length + '</div>';
|
||||
|
||||
if (result.months.length > 0) {
|
||||
displayMonthlyResults(result.months, result.globalParams);
|
||||
tabs[2].click();
|
||||
}
|
||||
} else {
|
||||
uploadStatus.innerHTML = '<div class="kt-ai-card" style="padding:var(--kt-ai-space-4);color:var(--kt-ai-danger)">Ошибка: ' + result.error + '</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
uploadStatus.innerHTML = '<div class="kt-ai-card" style="padding:var(--kt-ai-space-4);color:var(--kt-ai-danger)">Ошибка загрузки: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(results) {
|
||||
if (results.incoming) {
|
||||
document.getElementById('resIncoming').textContent = results.incoming.fte;
|
||||
@ -63,6 +141,71 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('resTotalWithAbsence').textContent = results.total.fteWithAbsence.toFixed(1);
|
||||
}
|
||||
|
||||
function displayMonthlyResults(months, params) {
|
||||
const totalFTE = months.reduce((sum, m) => sum + m.results.total.fteWithAbsence, 0);
|
||||
const avgFTE = totalFTE / months.length;
|
||||
|
||||
let html = `
|
||||
<div class="wfm-card" style="margin-bottom:var(--kt-ai-space-6)">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)">Итого за период</h3>
|
||||
<div class="results-grid">
|
||||
<div class="result-item">
|
||||
<div class="result-value">${avgFTE.toFixed(1)}</div>
|
||||
<div class="result-label">Среднее FTE в штате</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-value">${totalFTE.toFixed(1)}</div>
|
||||
<div class="result-label">Суммарно FTE (все месяцы)</div>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<div class="result-value">${months.length}</div>
|
||||
<div class="result-label">Месяцев в расчёте</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wfm-card">
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-4)">Детализация по месяцам</h3>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="monthly-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Месяц</th>
|
||||
<th>Входящие</th>
|
||||
<th>Исходящие</th>
|
||||
<th>Тикеты</th>
|
||||
<th>Чаты</th>
|
||||
<th>FTE в графике</th>
|
||||
<th>FTE в штате</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
months.forEach(m => {
|
||||
html += `
|
||||
<tr>
|
||||
<td><strong>${m.month}</strong></td>
|
||||
<td>${m.incomingCalls.toLocaleString('ru-RU')}</td>
|
||||
<td>${m.outgoingCalls ? m.outgoingCalls.toLocaleString('ru-RU') : '-'}</td>
|
||||
<td>${m.tickets ? m.tickets.toLocaleString('ru-RU') : '-'}</td>
|
||||
<td>${m.chats ? m.chats.toLocaleString('ru-RU') : '-'}</td>
|
||||
<td style="color:var(--kt-ai-fg)">${m.results.total.fteInSchedule.toFixed(1)}</td>
|
||||
<td style="color:var(--kt-ai-primary);font-weight:var(--kt-ai-weight-semibold)">${m.results.total.fteWithAbsence.toFixed(1)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
monthlyResults.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/history');
|
||||
@ -97,5 +240,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonthlyResults() {
|
||||
try {
|
||||
const response = await fetch('/api/monthly');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
monthlyResults.innerHTML = `
|
||||
<div class="wfm-card">
|
||||
<h3>Нет данных</h3>
|
||||
<p style="color:var(--kt-ai-fg-muted)">Загрузите Excel файл во вкладке "Загрузить Excel" для расчёта по месяцам</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
displayMonthlyResults(data, {});
|
||||
} catch (error) {
|
||||
console.error('Ошибка:', error);
|
||||
monthlyResults.innerHTML = '<div class="kt-ai-card" style="color:var(--kt-ai-danger)">Ошибка загрузки данных</div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
180
server.js
180
server.js
@ -1,12 +1,14 @@
|
||||
const http = require("http");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const XLSX = require("xlsx");
|
||||
const formidable = require("formidable");
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA = path.join(__dirname, "data.json");
|
||||
|
||||
function readData() {
|
||||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { calculations: [] }; }
|
||||
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { calculations: [], monthlyData: [] }; }
|
||||
}
|
||||
function writeData(data) {
|
||||
fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
|
||||
@ -41,7 +43,7 @@ function factorial(n) {
|
||||
// Расчёт требуемого количества агентов (Erlang C)
|
||||
function calculateAgents(contacts, ahtMin, serviceTimeSec, patienceSec, targetSL) {
|
||||
const ahtSec = ahtMin * 60;
|
||||
const trafficIntensity = (contacts * ahtSec) / 3600; // в Эрлангах за час
|
||||
const trafficIntensity = (contacts * ahtSec) / 3600;
|
||||
|
||||
let agents = Math.ceil(trafficIntensity);
|
||||
let sl = 0;
|
||||
@ -65,7 +67,6 @@ function calculateAgents(contacts, ahtMin, serviceTimeSec, patienceSec, targetSL
|
||||
function calculateFTE(params) {
|
||||
const results = {};
|
||||
|
||||
// Входящие звонки (Erlang C)
|
||||
if (params.incomingCalls && params.incomingCalls > 0) {
|
||||
const r = calculateAgents(
|
||||
params.incomingCalls,
|
||||
@ -81,7 +82,6 @@ function calculateFTE(params) {
|
||||
};
|
||||
}
|
||||
|
||||
// Исходящие звонки (простой расчёт по времени)
|
||||
if (params.outgoingCalls && params.outgoingCalls > 0) {
|
||||
const totalMinutes = params.outgoingCalls * (params.ahtOutgoing || 4.5);
|
||||
const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86);
|
||||
@ -91,7 +91,6 @@ function calculateFTE(params) {
|
||||
};
|
||||
}
|
||||
|
||||
// Неголосовые тикеты
|
||||
if (params.tickets && params.tickets > 0) {
|
||||
const totalMinutes = params.tickets * (params.ahtTickets || 12);
|
||||
const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86);
|
||||
@ -101,7 +100,6 @@ function calculateFTE(params) {
|
||||
};
|
||||
}
|
||||
|
||||
// Чаты (с учётом concurrency)
|
||||
if (params.chats && params.chats > 0) {
|
||||
const concurrency = params.concurrency || 4;
|
||||
const totalMinutes = (params.chats * (params.ahtChats || 90)) / concurrency;
|
||||
@ -113,7 +111,6 @@ function calculateFTE(params) {
|
||||
};
|
||||
}
|
||||
|
||||
// Итого
|
||||
results.total = {
|
||||
fteInSchedule: Object.values(results).reduce((sum, r) => sum + (r.fte || 0), 0),
|
||||
fteWithAbsence: 0,
|
||||
@ -121,15 +118,124 @@ function calculateFTE(params) {
|
||||
unplannedAbsence: params.unplannedAbsence || 0.05
|
||||
};
|
||||
|
||||
// Учёт отсутствий
|
||||
const absenceMultiplier = 1 / (1 - (results.total.absence + results.total.unplannedAbsence));
|
||||
results.total.fteWithAbsence = Math.ceil(results.total.fteInSchedule * absenceMultiplier * 10) / 10;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Парсинг Excel файла
|
||||
function parseExcelFile(filePath) {
|
||||
const wb = XLSX.readFile(filePath);
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
|
||||
const months = [];
|
||||
const globalParams = {
|
||||
ahtIncoming: 4.25,
|
||||
serviceTimeIncoming: 40,
|
||||
patienceIncoming: 40,
|
||||
targetSL: 80,
|
||||
ahtOutgoing: 4.5,
|
||||
ahtTickets: 12,
|
||||
ahtChats: 90,
|
||||
concurrency: 4,
|
||||
fteHours: 189,
|
||||
utilization: 0.86,
|
||||
plannedAbsence: 0.1,
|
||||
unplannedAbsence: 0.05
|
||||
};
|
||||
|
||||
for (let row of data) {
|
||||
if (!row || row.length === 0) continue;
|
||||
const firstCell = String(row[0] || '').toLowerCase();
|
||||
|
||||
if (firstCell.includes('aht') && firstCell.includes('вход')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.ahtIncoming = val;
|
||||
}
|
||||
if (firstCell.includes('service') && firstCell.includes('вход')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.serviceTimeIncoming = val;
|
||||
}
|
||||
if (firstCell.includes('patience')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.patienceIncoming = val;
|
||||
}
|
||||
if (firstCell.includes('target') || firstCell.includes('service level')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.targetSL = val;
|
||||
}
|
||||
if (firstCell.includes('aht') && firstCell.includes('исход')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.ahtOutgoing = val;
|
||||
}
|
||||
if (firstCell.includes('aht') && firstCell.includes('тикет')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.ahtTickets = val;
|
||||
}
|
||||
if (firstCell.includes('aht') && firstCell.includes('чат')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.ahtChats = val;
|
||||
}
|
||||
if (firstCell.includes('concurrency')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.concurrency = val;
|
||||
}
|
||||
if (firstCell.includes('fte') && firstCell.includes('час')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val) && val > 0) globalParams.fteHours = val;
|
||||
}
|
||||
if (firstCell.includes('utilization')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.utilization = val;
|
||||
}
|
||||
if (firstCell.includes('планов') && firstCell.includes('отсутств')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.plannedAbsence = val;
|
||||
}
|
||||
if (firstCell.includes('неплан') && firstCell.includes('отсутств')) {
|
||||
const val = parseFloat(row[1]);
|
||||
if (!isNaN(val)) globalParams.unplannedAbsence = val;
|
||||
}
|
||||
|
||||
if (firstCell.match(/^\d+$/) || firstCell.match(/^\d{1,2}[.\-]\d{1,2}$/) ||
|
||||
firstCell.includes('янв') || firstCell.includes('фев') || firstCell.includes('мар') ||
|
||||
firstCell.includes('апр') || firstCell.includes('май') || firstCell.includes('июн') ||
|
||||
firstCell.includes('июл') || firstCell.includes('авг') || firstCell.includes('сен') ||
|
||||
firstCell.includes('окт') || firstCell.includes('ноя') || firstCell.includes('дек') ||
|
||||
firstCell.includes('month')) {
|
||||
|
||||
let monthName = row[0];
|
||||
let incomingCalls = null, outgoingCalls = null, tickets = null, chats = null;
|
||||
|
||||
for (let i = 1; i < row.length; i++) {
|
||||
const cell = String(row[i] || '');
|
||||
const val = parseFloat(cell.replace(',', '.').replace(/\s/g, ''));
|
||||
if (!isNaN(val)) {
|
||||
if (incomingCalls === null) incomingCalls = val;
|
||||
else if (outgoingCalls === null) outgoingCalls = val;
|
||||
else if (tickets === null) tickets = val;
|
||||
else if (chats === null) chats = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingCalls !== null && incomingCalls > 0) {
|
||||
months.push({
|
||||
month: monthName,
|
||||
incomingCalls,
|
||||
outgoingCalls: outgoingCalls || 0,
|
||||
tickets: tickets || 0,
|
||||
chats: chats || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { months, globalParams };
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
// CORS для предпросмотра
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
@ -139,12 +245,10 @@ http.createServer(async (req, res) => {
|
||||
return res.end();
|
||||
}
|
||||
|
||||
// API: расчёт WFM
|
||||
if (req.method === "POST" && req.url === "/api/calculate") {
|
||||
const params = await body(req);
|
||||
const results = calculateFTE(params);
|
||||
|
||||
// Сохранение расчёта
|
||||
const data = readData();
|
||||
data.calculations.push({
|
||||
id: Date.now(),
|
||||
@ -158,14 +262,64 @@ http.createServer(async (req, res) => {
|
||||
return res.end(JSON.stringify(results));
|
||||
}
|
||||
|
||||
// API: история расчётов
|
||||
if (req.method === "POST" && req.url === "/api/upload") {
|
||||
const form = formidable({ uploadDir: __dirname, keepExtensions: true });
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
if (err) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
|
||||
const file = files.file && files.file[0];
|
||||
if (!file) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: "Файл не найден" }));
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseExcelFile(file.filepath);
|
||||
|
||||
const monthlyResults = parsed.months.map(m => ({
|
||||
...m,
|
||||
results: calculateFTE({
|
||||
...m,
|
||||
...parsed.globalParams,
|
||||
...fields
|
||||
})
|
||||
}));
|
||||
|
||||
const data = readData();
|
||||
data.monthlyData = monthlyResults;
|
||||
writeData(data);
|
||||
|
||||
fs.unlinkSync(file.filepath);
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({
|
||||
ok: true,
|
||||
months: monthlyResults,
|
||||
globalParams: parsed.globalParams
|
||||
}));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/history") {
|
||||
const data = readData();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(data.calculations.slice(-10).reverse()));
|
||||
}
|
||||
|
||||
// Статика
|
||||
if (req.method === "GET" && req.url === "/api/monthly") {
|
||||
const data = readData();
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(data.monthlyData));
|
||||
}
|
||||
|
||||
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
|
||||
const full = path.join(__dirname, file);
|
||||
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user