Обновление дашборда с загрузкой файлов
This commit is contained in:
parent
29a38a42dc
commit
4fc0930592
15
index.html
15
index.html
@ -107,6 +107,21 @@ body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,s
|
|||||||
<div id="tab-dashboard" class="tab-content hidden">
|
<div id="tab-dashboard" class="tab-content hidden">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-xl font-bold mb-4">📊 Дашборд обучения ТБ</h2>
|
<h2 class="text-xl font-bold mb-4">📊 Дашборд обучения ТБ</h2>
|
||||||
|
<div class="mb-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-blue-800">📤 Загрузка данных</p>
|
||||||
|
<p class="text-sm text-blue-600">Загрузите Excel (.xlsx) или CSV файл с данными сотрудников</p>
|
||||||
|
</div>
|
||||||
|
<label class="cursor-pointer">
|
||||||
|
<div class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition flex items-center gap-2">
|
||||||
|
<span>📁 Выбрать файл</span>
|
||||||
|
<input type="file" id="file-upload" accept=".csv,.xlsx" class="hidden" onchange="handleFileUpload(event)">
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="upload-results" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6" id="dash-stats"></div>
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6" id="dash-stats"></div>
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
258
script.js
258
script.js
@ -322,6 +322,230 @@ function showCityEmployees(city) {
|
|||||||
showTab('search');
|
showTab('search');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== ЗАГРУЗКА ДАННЫХ ==========
|
||||||
|
function handleFileUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
const extension = file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
reader.onload = function(e) {
|
||||||
|
let csvData = e.target.result;
|
||||||
|
let parsedData = null;
|
||||||
|
let fileType = '';
|
||||||
|
|
||||||
|
if (extension === 'csv') {
|
||||||
|
parsedData = parseCSV(csvData);
|
||||||
|
fileType = 'CSV';
|
||||||
|
} else if (extension === 'xlsx') {
|
||||||
|
try {
|
||||||
|
parsedData = parseExcel(e.target.result);
|
||||||
|
fileType = 'Excel';
|
||||||
|
} catch (err) {
|
||||||
|
showUploadMessage(`Ошибка при чтении Excel файла: ${err.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showUploadMessage('Поддерживаются только файлы .csv и .xlsx', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedData && parsedData.length > 0) {
|
||||||
|
showUploadMessage(`Файл успешно загружен! Найдено ${parsedData.length} строк данных.`, 'success');
|
||||||
|
processUploadedData(parsedData, fileType);
|
||||||
|
} else {
|
||||||
|
showUploadMessage('Не удалось распознать данные в файле. Проверьте формат CSV/Excel.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = function() {
|
||||||
|
showUploadMessage('Ошибка при чтении файла', 'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (extension === 'csv') {
|
||||||
|
reader.readAsText(file);
|
||||||
|
} else if (extension === 'xlsx') {
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(csvData) {
|
||||||
|
const lines = csvData.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
|
||||||
|
const headers = lines[0].split(';').map(h => h.trim());
|
||||||
|
const data = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const values = lines[i].split(';');
|
||||||
|
if (values.length < 3) continue;
|
||||||
|
|
||||||
|
const row = {};
|
||||||
|
for (let j = 0; j < headers.length; j++) {
|
||||||
|
if (j < values.length) {
|
||||||
|
row[headers[j]] = values[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExcel(arrayBuffer) {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(arrayBuffer);
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[sheetName];
|
||||||
|
const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||||
|
|
||||||
|
const headers = json[0] ? json[0].map(h => {
|
||||||
|
if (typeof h === 'string') return h.trim();
|
||||||
|
return String(h).trim();
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
const data = [];
|
||||||
|
for (let i = 1; i < json.length; i++) {
|
||||||
|
if (!json[i] || json[i].length === 0) continue;
|
||||||
|
|
||||||
|
const row = {};
|
||||||
|
for (let j = 0; j < headers.length; j++) {
|
||||||
|
if (j < json[i].length) {
|
||||||
|
row[headers[j]] = json[i][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка парсинга Excel:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processUploadedData(uploadedData, fileType) {
|
||||||
|
try {
|
||||||
|
const processedEmployees = [];
|
||||||
|
|
||||||
|
for (const row of uploadedData) {
|
||||||
|
const emp = {
|
||||||
|
tab: row['Таб.№'] || row['tab'] || row['Табельный'] || '',
|
||||||
|
lastName: row['ФИО'] ? row['ФИО'].split(' ')[0] : '',
|
||||||
|
firstName: row['ФИО'] && row['ФИО'].includes(' ') ? row['ФИО'].split(' ')[1].split(' ')[0] : '',
|
||||||
|
patronymic: row['ФИО'] && row['ФИО'].split(' ').length > 2 ? row['ФИО'].split(' ')[2] : '',
|
||||||
|
fullName: row['ФИО'] || row['ФИО'] || '',
|
||||||
|
city: row['Город'] || '',
|
||||||
|
department: row['Подразделение'] || '',
|
||||||
|
position: row['Должность'] || row['Штатная должность'] || '',
|
||||||
|
courses: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!emp.tab || !emp.fullName) continue;
|
||||||
|
|
||||||
|
const courseHeaders = ['Курс1', 'Курс2', 'Курс3', 'Курс4', 'Курс5'];
|
||||||
|
const statusHeaders = ['Статус1', 'Статус2', 'Статус3', 'Статус4', 'Статус5'];
|
||||||
|
const dateHeaders = ['Дата1', 'Дата2', 'Дата3', 'Дата4', 'Дата5'];
|
||||||
|
|
||||||
|
let courseIndex = 1;
|
||||||
|
for (const header of courseHeaders) {
|
||||||
|
const courseName = row[header];
|
||||||
|
if (!courseName) break;
|
||||||
|
|
||||||
|
const status = row[statusHeaders[courseIndex - 1]] || row[`Статус ${courseIndex}`] || '';
|
||||||
|
const dateStr = row[dateHeaders[courseIndex - 1]] || row[`Дата ${courseIndex}`] || '';
|
||||||
|
|
||||||
|
const course = {
|
||||||
|
courseId: courseIndex,
|
||||||
|
courseName: courseName,
|
||||||
|
level: Math.floor(Math.random() * 2) + 1,
|
||||||
|
completed: status.toLowerCase() === 'пройдено' || status.toLowerCase() === 'yes' || status.toLowerCase() === 'true',
|
||||||
|
completedDate: dateStr && dateStr.match(/\d{2}\.\d{2}\.\d{4}/) ? dateStr.match(/\d{2}\.\d{2}\.\d{4}/)[0] : '',
|
||||||
|
nextDate: '',
|
||||||
|
overdue: false,
|
||||||
|
daysOverdue: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (course.completed && course.completedDate) {
|
||||||
|
const [day, month, year] = course.completedDate.split('.');
|
||||||
|
const completedDate = new Date(`${year}-${month}-${day}`);
|
||||||
|
course.nextDate = new Date(completedDate);
|
||||||
|
course.nextDate.setMonth(course.nextDate.getMonth() + 12);
|
||||||
|
course.nextDate = course.nextDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
course.overdue = new Date(course.nextDate) < today;
|
||||||
|
course.daysOverdue = course.overdue ? Math.floor((today - new Date(course.nextDate)) / 86400000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
emp.courses.push(course);
|
||||||
|
courseIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedEmployees.push(emp);
|
||||||
|
}
|
||||||
|
|
||||||
|
EMPLOYEES.length = 0;
|
||||||
|
EMPLOYEES.push(...processedEmployees);
|
||||||
|
|
||||||
|
saveUploadedDataToLocalStorage(processedEmployees, fileType);
|
||||||
|
updateDashboardAfterUpload();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showUploadMessage('Ошибка обработки данных: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveUploadedDataToLocalStorage(data, fileType) {
|
||||||
|
try {
|
||||||
|
const savedData = {
|
||||||
|
employees: data,
|
||||||
|
fileType: fileType,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
version: '1.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('tb_assistant_uploaded_data', JSON.stringify(savedData));
|
||||||
|
localStorage.setItem('tb_assistant_last_upload', new Date().toISOString());
|
||||||
|
|
||||||
|
showUploadMessage('Данные успешно загружены и сохранены!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showUploadMessage('Данные загружены, но не сохранены в локальное хранилище: ' + error.message, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardAfterUpload() {
|
||||||
|
if (currentUser && isAdmin) {
|
||||||
|
if (document.getElementById('tab-dashboard').classList.contains('hidden')) {
|
||||||
|
showTab('dashboard');
|
||||||
|
}
|
||||||
|
renderDashboard();
|
||||||
|
renderCityStats();
|
||||||
|
if (document.getElementById('admin-panel')) {
|
||||||
|
document.getElementById('admin-panel').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUploadMessage(message, type) {
|
||||||
|
const container = document.getElementById('upload-results');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const msgDiv = document.createElement('div');
|
||||||
|
msgDiv.className = `p-3 mb-2 rounded text-sm ${type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' : type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-yellow-50 text-yellow-700 border border-yellow-200'}`;
|
||||||
|
msgDiv.textContent = message;
|
||||||
|
|
||||||
|
container.insertBefore(msgDiv, container.firstChild);
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (msgDiv.parentNode) msgDiv.parentNode.removeChild(msgDiv);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ДАШБОРД ==========
|
// ========== ДАШБОРД ==========
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
const totalEmp = EMPLOYEES.length;
|
const totalEmp = EMPLOYEES.length;
|
||||||
@ -360,6 +584,40 @@ function renderDashboard() {
|
|||||||
return { name: c.name, notDone, pct: Math.round((notDone / total) * 100) };
|
return { name: c.name, notDone, pct: Math.round((notDone / total) * 100) };
|
||||||
}).sort((a, b) => b.pct - a.pct).slice(0, 5);
|
}).sort((a, b) => b.pct - a.pct).slice(0, 5);
|
||||||
|
|
||||||
|
document.getElementById('dash-courses').innerHTML = courseStats.map((s, i) =>
|
||||||
|
`<div class="flex justify-between items-center py-2 ${i < courseStats.length - 1 ? 'border-b' : ''}">
|
||||||
|
<span class="text-sm">${i + 1}. ${s.name}</span>
|
||||||
|
<span class="status-danger text-sm">${s.pct}%</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
<div class="card text-center"><div class="text-3xl font-bold">${totalEmp}</div><div class="text-sm text-gray-500">Сотрудников</div></div>
|
||||||
|
<div class="card text-center"><div class="text-3xl font-bold">${progress}%</div><div class="text-sm text-gray-500">Выполнено</div></div>
|
||||||
|
<div class="card text-center"><div class="text-3xl font-bold text-red-500">${overdue}</div><div class="text-sm text-gray-500">Просрочено</div></div>
|
||||||
|
<div class="card text-center"><div class="text-3xl font-bold text-yellow-500">${notStarted}</div><div class="text-sm text-gray-500">Не начато</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Проблемные филиалы
|
||||||
|
const cityStats = CITIES.map(city => {
|
||||||
|
const emps = EMPLOYEES.filter(e => e.city === city);
|
||||||
|
const ov = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0);
|
||||||
|
const total = emps.reduce((sum, e) => sum + e.courses.length, 0);
|
||||||
|
return { city, overdue: ov, pct: total > 0 ? Math.round((ov / total) * 100) : 0 };
|
||||||
|
}).sort((a, b) => b.pct - a.pct);
|
||||||
|
|
||||||
|
document.getElementById('dash-problems').innerHTML = cityStats.map((s, i) =>
|
||||||
|
`<div class="flex justify-between items-center py-2 ${i < cityStats.length - 1 ? 'border-b' : ''}">
|
||||||
|
<span>${i + 1}. ${s.city}</span>
|
||||||
|
<span class="status-danger">${s.pct}% просрочено (${s.overdue})</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Самые непройденные курсы
|
||||||
|
const courseStats = COURSES.map(c => {
|
||||||
|
const total = EMPLOYEES.length;
|
||||||
|
const notDone = EMPLOYEES.filter(e => !e.courses.find(cc => cc.courseId === c.id && cc.completed)).length;
|
||||||
|
return { name: c.name, notDone, pct: Math.round((notDone / total) * 100) };
|
||||||
|
}).sort((a, b) => b.pct - a.pct).slice(0, 5);
|
||||||
|
|
||||||
document.getElementById('dash-courses').innerHTML = courseStats.map((s, i) =>
|
document.getElementById('dash-courses').innerHTML = courseStats.map((s, i) =>
|
||||||
`<div class="flex justify-between items-center py-2 ${i < courseStats.length - 1 ? 'border-b' : ''}">
|
`<div class="flex justify-between items-center py-2 ${i < courseStats.length - 1 ? 'border-b' : ''}">
|
||||||
<span class="text-sm">${i + 1}. ${s.name}</span>
|
<span class="text-sm">${i + 1}. ${s.name}</span>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user