-
-
+
+
-
-
-
-
Исходящие звонки
-
-
-
-
-
-
-
-
+
+
-
-
-
-
Неголосовые тикеты
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Загрузите Excel файл
+
Перетащите файл или нажмите для выбора
+
Формат: как в примере "Расчет численности ЦОО 2026 v1.xlsx"
+
-
-
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index efe9f2c..82b09fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
+ }
}
}
}
diff --git a/package.json b/package.json
index 42bc1c6..5ae06b6 100644
--- a/package.json
+++ b/package.json
@@ -4,5 +4,9 @@
"type": "commonjs",
"scripts": {
"start": "node server.js"
+ },
+ "dependencies": {
+ "xlsx": "^0.18.5",
+ "formidable": "^3.5.1"
}
}
diff --git a/script.js b/script.js
index f77431b..fcbd13d 100644
--- a/script.js
+++ b/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 = '
Загрузка файла...
';
+
+ 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 = '
✅ Файл загружен! Найдено месяцев: ' + result.months.length + '
';
+
+ if (result.months.length > 0) {
+ displayMonthlyResults(result.months, result.globalParams);
+ tabs[2].click();
+ }
+ } else {
+ uploadStatus.innerHTML = '
Ошибка: ' + result.error + '
';
+ }
+ } catch (error) {
+ uploadStatus.innerHTML = '
Ошибка загрузки: ' + error.message + '
';
+ }
+ }
+
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 = `
+
+
Итого за период
+
+
+
${avgFTE.toFixed(1)}
+
Среднее FTE в штате
+
+
+
${totalFTE.toFixed(1)}
+
Суммарно FTE (все месяцы)
+
+
+
${months.length}
+
Месяцев в расчёте
+
+
+
+
+
+
Детализация по месяцам
+
+
+
+
+ | Месяц |
+ Входящие |
+ Исходящие |
+ Тикеты |
+ Чаты |
+ FTE в графике |
+ FTE в штате |
+
+
+
+ `;
+
+ months.forEach(m => {
+ html += `
+
+ | ${m.month} |
+ ${m.incomingCalls.toLocaleString('ru-RU')} |
+ ${m.outgoingCalls ? m.outgoingCalls.toLocaleString('ru-RU') : '-'} |
+ ${m.tickets ? m.tickets.toLocaleString('ru-RU') : '-'} |
+ ${m.chats ? m.chats.toLocaleString('ru-RU') : '-'} |
+ ${m.results.total.fteInSchedule.toFixed(1)} |
+ ${m.results.total.fteWithAbsence.toFixed(1)} |
+
+ `;
+ });
+
+ html += `
+
+
+
+
+ `;
+
+ 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 = `
+
+
Нет данных
+
Загрузите Excel файл во вкладке "Загрузить Excel" для расчёта по месяцам
+
+ `;
+ return;
+ }
+
+ displayMonthlyResults(data, {});
+ } catch (error) {
+ console.error('Ошибка:', error);
+ monthlyResults.innerHTML = '
Ошибка загрузки данных
';
+ }
+ }
+
loadHistory();
});
diff --git a/server.js b/server.js
index e2d7949..e9122ab 100644
--- a/server.js
+++ b/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()) {