ot-tb-control/index.html

795 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Учёт внутренних контролей по ОТ и ТБ</title>
<script src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js"></script>
<style>
:root{
--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;
--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--gray-200:#E4E7EC;
--red:#EF4444;--amber:#F59E0B;--green:#10B981;
--red-bg:#FEF2F2;--amber-bg:#FFFBEB;--green-bg:#ECFDF5;
--shadow:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.06);
--radius:12px;--radius-sm:8px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
color:var(--ink);background:var(--gray-100);min-height:100vh;
}
.header{background:var(--white);border-bottom:1px solid var(--gray-200);position:sticky;top:0;z-index:100}
.header-inner{max-width:1300px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between;height:58px}
.header h1{font-size:17px;font-weight:700}
.header-badge{font-size:11px;background:var(--cyan-50);color:var(--ink);padding:3px 10px;border-radius:20px;font-weight:600}
.tabs{background:var(--white);border-bottom:1px solid var(--gray-200);position:sticky;top:58px;z-index:99}
.tabs-inner{max-width:1300px;margin:0 auto;padding:0 24px;display:flex;gap:0}
.tab{
padding:13px 22px;font-size:14px;font-weight:500;color:var(--gray-500);
border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;
transition:all .15s;white-space:nowrap;
}
.tab:hover{color:var(--ink)}
.tab.active{color:var(--ink);border-bottom-color:var(--cyan);font-weight:600}
.main{max-width:1300px;margin:0 auto;padding:28px 24px}
.tab-panel{display:none}
.tab-panel.active{display:block}
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:20px}
.stat-card{background:var(--white);border-radius:var(--radius);padding:18px 22px;box-shadow:var(--shadow);cursor:default}
.stat-card .stat-label{font-size:12px;color:var(--gray-500);margin-bottom:2px}
.stat-card .stat-value{font-size:28px;font-weight:800;line-height:1}
.table-wrap{background:var(--white);border-radius:var(--radius);box-shadow:var(--shadow);overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:13px;white-space:nowrap}
th{text-align:left;padding:10px 14px;background:var(--gray-100);font-weight:600;font-size:11px;color:var(--gray-500);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--gray-200);cursor:pointer;user-select:none}
th:hover{color:var(--ink)}
th .sort-arrow{font-size:10px;margin-left:3px}
td{padding:10px 14px;border-bottom:1px solid var(--gray-100)}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--cyan-50)}
.badge{display:inline-block;padding:2px 9px;border-radius:10px;font-size:11px;font-weight:600}
.badge-red{background:var(--red-bg);color:var(--red)}
.badge-amber{background:var(--amber-bg);color:var(--amber)}
.badge-green{background:var(--green-bg);color:var(--green)}
.badge-info{background:var(--cyan-50);color:var(--ink)}
.btn{
display:inline-flex;align-items:center;gap:5px;padding:7px 14px;
border-radius:var(--radius-sm);font-size:13px;font-weight:600;
border:none;cursor:pointer;transition:all .15s;line-height:1.4;
}
.btn-primary{background:var(--cyan);color:var(--ink)}
.btn-primary:hover{background:#1be5ff}
.btn-outline{background:transparent;color:var(--ink);border:1.5px solid var(--gray-200)}
.btn-outline:hover{border-color:var(--ink)}
.btn-sm{padding:4px 9px;font-size:11px}
.btn-success{background:var(--green-bg);color:var(--green);border:1px solid var(--green-bg)}
.btn-success:hover{background:var(--green);color:#fff}
.btn-danger{background:var(--red-bg);color:var(--red);border:1px solid var(--red-bg)}
.btn-danger:hover{background:var(--red);color:#fff}
.btn-group{display:flex;gap:4px}
.toolbar{display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap;align-items:center}
.toolbar select,.toolbar input{
padding:7px 11px;border:1.5px solid var(--gray-200);border-radius:var(--radius-sm);
font-size:13px;background:var(--white);color:var(--ink);
}
.toolbar select:focus,.toolbar input:focus{outline:none;border-color:var(--cyan)}
.toolbar .sep{width:1px;height:28px;background:var(--gray-200);margin:0 2px}
.form-card{background:var(--white);border-radius:var(--radius);padding:22px 26px;box-shadow:var(--shadow);max-width:700px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.form-group{margin-bottom:14px}
.form-group label{display:block;font-size:12px;font-weight:600;margin-bottom:3px}
.form-group input,.form-group select,.form-group textarea{
width:100%;padding:9px 11px;border:1.5px solid var(--gray-200);
border-radius:var(--radius-sm);font-size:13px;color:var(--ink);font-family:inherit;
}
.form-group textarea{resize:vertical;min-height:60px}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--cyan)}
.modal-overlay{
position:fixed;inset:0;background:rgba(15,18,24,.5);z-index:200;
display:flex;align-items:center;justify-content:center;padding:24px;
}
.modal{
background:var(--white);border-radius:var(--radius);padding:26px;
width:100%;max-height:90vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.15);
}
.modal.wide{max-width:900px}
.modal h3{font-size:17px;font-weight:700;margin-bottom:14px}
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
.empty{text-align:center;padding:40px 20px;color:var(--gray-500)}
.chart-card{background:var(--white);border-radius:var(--radius);padding:22px;box-shadow:var(--shadow);margin-bottom:18px}
.chart-card h3{font-size:15px;font-weight:700;margin-bottom:13px}
.bar-row{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:12px}
.bar-label{min-width:200px;text-align:right}
.bar-track{flex:1;background:var(--gray-100);border-radius:3px;height:18px;overflow:hidden}
.bar-fill{height:100%;border-radius:3px;transition:width .4s;min-width:2px;background:var(--cyan)}
.bar-num{font-weight:600;min-width:40px;text-align:right}
/* Import preview */
.import-preview{max-height:300px;overflow:auto;margin:14px 0;border:1px solid var(--gray-200);border-radius:var(--radius-sm)}
.import-preview table{font-size:12px}
.import-preview td{padding:6px 10px}
.import-info{font-size:13px;color:var(--gray-500);margin:10px 0}
/* Clickable link */
.link{color:var(--cyan);cursor:pointer;font-weight:600;text-decoration:underline;text-underline-offset:2px}
.link:hover{color:#0cc}
@media (max-width:768px){
.tabs-inner{overflow-x:auto}
.tab{padding:11px 14px;font-size:12px}
.stats-row{grid-template-columns:1fr 1fr}
.form-row{grid-template-columns:1fr}
table{font-size:11px}
th,td{padding:7px 8px}
.bar-label{min-width:120px;font-size:11px}
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<h1>Учёт внутренних контролей по ОТ и ТБ</h1>
<span class="header-badge">Охрана труда</span>
</div>
</header>
<nav class="tabs">
<div class="tabs-inner" id="tabNav">
<button class="tab active" data-tab="dashboard">Сводка</button>
<button class="tab" data-tab="table">Таблица учёта</button>
<button class="tab" data-tab="directives">По указаниям</button>
<button class="tab" data-tab="add">Добавить запись</button>
</div>
</nav>
<main class="main">
<!-- DASHBOARD -->
<section class="tab-panel active" id="tab-dashboard">
<div class="stats-row" id="statsRow"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px" id="dashCharts">
<div class="chart-card" id="chartByUnit"></div>
<div class="chart-card" id="chartByCategory"></div>
</div>
</section>
<!-- TABLE -->
<section class="tab-panel" id="tab-table">
<div class="toolbar">
<select id="fUnit" onchange="renderTable()"><option value="all">Все подразделения</option></select>
<select id="fRegion" onchange="renderTable()"><option value="all">Все регионы</option></select>
<select id="fCategory" onchange="renderTable()"><option value="all">Все категории</option></select>
<select id="fMonth" onchange="renderTable()">
<option value="all">Все месяцы</option>
<option>Январь</option><option>Февраль</option><option>Март</option><option>Апрель</option>
<option>Май</option><option>Июнь</option><option>Июль</option><option>Август</option>
<option>Сентябрь</option><option>Октябрь</option><option>Ноябрь</option><option>Декабрь</option>
</select>
<select id="fYear" onchange="renderTable()"><option value="all">Все годы</option></select>
<span class="sep"></span>
<button class="btn btn-outline btn-sm" onclick="exportExcel()">Экспорт Excel</button>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('importFile').click()">Импорт Excel</button>
<input type="file" id="importFile" accept=".xlsx,.xls,.docx" style="display:none" onchange="importFile(this)">
</div>
<div class="table-wrap" id="dataTable"></div>
</section>
<!-- BY DIRECTIVES -->
<section class="tab-panel" id="tab-directives">
<div class="toolbar">
<select id="fDirNo" onchange="renderDirectives()"><option value="all">Все указания</option></select>
<select id="fDirCategory" onchange="renderDirectives()"><option value="all">Все категории</option></select>
</div>
<div id="directivesContent"></div>
</section>
<!-- ADD -->
<section class="tab-panel" id="tab-add">
<div class="form-card">
<h3 style="margin-bottom:14px;font-size:17px">Новая запись</h3>
<div class="form-row">
<div class="form-group"><label>Наименование структурного подразделения</label><input id="afUnit" placeholder="Например: Цех №3"></div>
<div class="form-group"><label>Населённый пункт</label><input id="afCity" placeholder="Например: г. Алматы"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Регион</label><input id="afRegion" placeholder="Например: Алматинская обл."></div>
<div class="form-group"><label>Месяц</label><select id="afMonth">
<option value="">— выберите —</option>
<option>Январь</option><option>Февраль</option><option>Март</option><option>Апрель</option>
<option>Май</option><option>Июнь</option><option>Июль</option><option>Август</option>
<option>Сентябрь</option><option>Октябрь</option><option>Ноябрь</option><option>Декабрь</option>
</select></div>
</div>
<div class="form-row">
<div class="form-group"><label>Год</label><input type="number" id="afYear" min="2020" max="2030"></div>
<div class="form-group"><label>Категория</label><select id="afCategory"></select></div>
</div>
<div class="form-row">
<div class="form-group"><label>План (кол-во проверок)</label><input type="number" id="afPlan" min="0"></div>
<div class="form-group"><label>Факт (выполнено)</label><input type="number" id="afFact" min="0"></div>
</div>
<div class="form-row">
<div class="form-group"><label>№ Указания</label><input id="afDirNo" placeholder="Например: 45/ОТ"></div>
<div class="form-group"><label>Указание оформил</label><input id="afDirBy" placeholder="ФИО"></div>
</div>
<div class="form-group"><label>Акт приостановки</label><input id="afSusp" placeholder="Например: Акт №12 от 15.03.2026"></div>
<button class="btn btn-primary" onclick="addEntry()" style="margin-top:6px">Сохранить запись</button>
</div>
</section>
</main>
<div class="modal-overlay" id="editModal" style="display:none" onclick="if(event.target===this)closeEditModal()">
<div class="modal" id="editModalContent"></div>
</div>
<div class="modal-overlay" id="importModal" style="display:none" onclick="if(event.target===this)closeImportModal()">
<div class="modal wide" id="importModalContent"></div>
</div>
<script>
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const CATEGORIES = [
'Несоблюдение требований безопасности',
'Отсутствие / неисправность СИЗ',
'Нарушение эксплуатации оборудования',
'Отсутствие инструктажа / обучения',
'Несоблюдение пожарной безопасности',
'Нарушение электробезопасности',
'Отсутствие ограждений / знаков',
'Захламлённость проходов и выходов',
'Нарушение правил работы на высоте',
'Другое'
];
function load(k,f){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):f } catch{ return f } }
function save(k,v){ localStorage.setItem(k,JSON.stringify(v)) }
let entries = load('ot_entries', []);
function genId(){ return 'e'+Date.now()+'_'+Math.random().toString(36).slice(2,6) }
/* ========== NAV ========== */
document.getElementById('tabNav').addEventListener('click', function(e){
if(!e.target.classList.contains('tab')) return;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
e.target.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.getElementById('tab-'+e.target.dataset.tab).classList.add('active');
switchTab(e.target.dataset.tab);
});
function switchTab(n){
if(n==='dashboard') renderDashboard();
else if(n==='table') renderTable();
else if(n==='directives') renderDirectives();
else if(n==='add') prepareAdd();
}
/* ========== DASHBOARD ========== */
function renderDashboard(){
const total=entries.length;
const totalPlan=entries.reduce((s,e)=>s+(+e.plan||0),0);
const totalFact=entries.reduce((s,e)=>s+(+e.fact||0),0);
const pct=totalPlan?Math.round(totalFact/totalPlan*100):0;
document.getElementById('statsRow').innerHTML=`
<div class="stat-card"><div class="stat-label">Всего записей</div><div class="stat-value">${total}</div></div>
<div class="stat-card"><div class="stat-label">План проверок</div><div class="stat-value">${totalPlan}</div></div>
<div class="stat-card"><div class="stat-label">Факт выполнено</div><div class="stat-value">${totalFact}</div></div>
<div class="stat-card"><div class="stat-label">Выполнение плана</div><div class="stat-value" style="color:${pct>=100?'var(--green)':pct>=70?'var(--amber)':'var(--red)'}">${pct}%</div></div>
<div class="stat-card"><div class="stat-label">Подразделений</div><div class="stat-value">${new Set(entries.map(e=>e.unit)).size}</div></div>
<div class="stat-card"><div class="stat-label">Указаний оформлено</div><div class="stat-value">${new Set(entries.filter(e=>e.directiveNo).map(e=>e.directiveNo)).size}</div></div>
`;
const byUnit={};
entries.forEach(e=>{ const u=e.unit||'—'; byUnit[u]=(byUnit[u]||0)+(+e.fact||0) });
const sorted=Object.entries(byUnit).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxU=Math.max(1,...sorted.map(s=>s[1]));
let h1='<h3>Выполнено проверок по подразделениям</h3>';
sorted.forEach(([u,c])=>{
h1+=`<div class="bar-row"><span class="bar-label">${esc(u)}</span><div class="bar-track"><div class="bar-fill" style="width:${(c/maxU*100).toFixed(0)}%"></div></div><span class="bar-num">${c}</span></div>`;
});
if(!sorted.length) h1+='<div class="empty">Нет данных</div>';
document.getElementById('chartByUnit').innerHTML=h1;
const byCat={};
CATEGORIES.forEach(c=>{byCat[c]=0});
entries.forEach(e=>{ const c=e.category; if(byCat[c]!=null) byCat[c]+=(+e.fact||0) });
const sortedC=Object.entries(byCat).sort((a,b)=>b[1]-a[1]).slice(0,8);
const maxC=Math.max(1,...sortedC.map(s=>s[1]));
let h2='<h3>Выполнено проверок по категориям</h3>';
sortedC.forEach(([c,n])=>{
h2+=`<div class="bar-row"><span class="bar-label">${esc(c)}</span><div class="bar-track"><div class="bar-fill" style="width:${(n/maxC*100).toFixed(0)}%"></div></div><span class="bar-num">${n}</span></div>`;
});
if(!sortedC.filter(s=>s[1]>0).length) h2+='<div class="empty">Нет данных</div>';
document.getElementById('chartByCategory').innerHTML=h2;
}
/* ========== TABLE ========== */
let sortCol=null, sortDir=1;
function renderTable(){
const fUnit=document.getElementById('fUnit').value;
const fRegion=document.getElementById('fRegion').value;
const fCategory=document.getElementById('fCategory').value;
const fMonth=document.getElementById('fMonth').value;
const fYear=document.getElementById('fYear').value;
let list=entries.slice();
if(fUnit!=='all') list=list.filter(e=>e.unit===fUnit);
if(fRegion!=='all') list=list.filter(e=>e.region===fRegion);
if(fCategory!=='all') list=list.filter(e=>e.category===fCategory);
if(fMonth!=='all') list=list.filter(e=>e.month===fMonth);
if(fYear!=='all') list=list.filter(e=>String(e.year)===fYear);
list.sort((a,b)=>(b.year-a.year)||(MONTHS.indexOf(b.month)-MONTHS.indexOf(a.month)));
if(sortCol){
const c=sortCol;
list.sort((a,b)=>{
let va=a[c]||'', vb=b[c]||'';
if(typeof va==='number') return (va-vb)*sortDir;
return String(va).localeCompare(String(vb))*sortDir;
});
}
function execBadge(p,f){
const r=p?Math.round(f/p*100):0;
if(!p) return '<span class="badge badge-info">—</span>';
if(r>=100) return `<span class="badge badge-green">${r}%</span>`;
if(r>=70) return `<span class="badge badge-amber">${r}%</span>`;
return `<span class="badge badge-red">${r}%</span>`;
}
function thSort(label,col){
const arrow=sortCol===col?(sortDir===1?' ▲':' ▼'):'';
return `<th onclick="sortBy('${col}')">${label}<span class="sort-arrow">${arrow}</span></th>`;
}
let html=`<table><thead><tr>
${thSort('Подразделение','unit')}${thSort('Нас. пункт','city')}${thSort('Регион','region')}
${thSort('Месяц','month')}${thSort('Год','year')}${thSort('Категория','category')}
${thSort('План','plan')}${thSort('Факт','fact')}
<th>% исп.</th>${thSort('№ Указания','directiveNo')}${thSort('Оформил','directiveBy')}
${thSort('Акт приостановки','suspensionAct')}<th></th>
</tr></thead><tbody>`;
if(!list.length) html+='<tr><td colspan="13" class="empty">Нет записей. <a class="link" onclick="switchTab(\'add\');document.querySelectorAll(\'.tab\').forEach(t=>t.classList.remove(\'active\'));document.querySelector(\'[data-tab=add]\').classList.add(\'active\')">Добавить</a></td></tr>';
else list.forEach(e=>{
html+=`<tr>
<td><strong>${esc(e.unit)}</strong></td>
<td>${esc(e.city)}</td>
<td>${esc(e.region)}</td>
<td>${esc(e.month)}</td>
<td>${e.year}</td>
<td><span class="badge badge-info">${esc(e.category||'—')}</span></td>
<td style="text-align:center">${e.plan||0}</td>
<td style="text-align:center">${e.fact||0}</td>
<td style="text-align:center">${execBadge(e.plan,e.fact)}</td>
<td>${e.directiveNo?`<span class="link" onclick="switchTab('directives');document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));document.querySelector('[data-tab=directives]').classList.add('active');document.getElementById('fDirNo').value='${escAttr(e.directiveNo)}';renderDirectives()">${esc(e.directiveNo)}</span>`:'—'}</td>
<td>${esc(e.directiveBy||'—')}</td>
<td>${esc(e.suspensionAct||'—')}</td>
<td><div class="btn-group">
<button class="btn btn-sm btn-outline" onclick="editEntry('${e.id}')">✏️</button>
<button class="btn btn-sm btn-danger" onclick="deleteEntry('${e.id}')">🗑</button>
</div></td>
</tr>`;
});
html+='</tbody></table>';
document.getElementById('dataTable').innerHTML=html;
updateFilters();
}
function sortBy(col){
if(sortCol===col) sortDir*=-1; else {sortCol=col;sortDir=1}
renderTable();
}
function updateFilters(){
const units=[...new Set(entries.map(e=>e.unit).filter(Boolean))].sort();
const regions=[...new Set(entries.map(e=>e.region).filter(Boolean))].sort();
const cats=[...new Set(entries.map(e=>e.category).filter(Boolean))].sort();
const years=[...new Set(entries.map(e=>e.year).filter(Boolean))].sort((a,b)=>b-a);
const cu=document.getElementById('fUnit').value;
const cr=document.getElementById('fRegion').value;
const cc=document.getElementById('fCategory').value;
const cy=document.getElementById('fYear').value;
document.getElementById('fUnit').innerHTML='<option value="all">Все подразделения</option>'+units.map(u=>`<option value="${escAttr(u)}" ${cu===u?'selected':''}>${esc(u)}</option>`).join('');
document.getElementById('fRegion').innerHTML='<option value="all">Все регионы</option>'+regions.map(r=>`<option value="${escAttr(r)}" ${cr===r?'selected':''}>${esc(r)}</option>`).join('');
document.getElementById('fCategory').innerHTML='<option value="all">Все категории</option>'+cats.map(c=>`<option value="${escAttr(c)}" ${cc===c?'selected':''}>${esc(c)}</option>`).join('');
document.getElementById('fYear').innerHTML='<option value="all">Все годы</option>'+years.map(y=>`<option value="${y}" ${cy===String(y)?'selected':''}>${y}</option>`).join('');
}
/* ========== DIRECTIVES VIEW ========== */
function renderDirectives(){
const fDir=document.getElementById('fDirNo').value;
const fCat=document.getElementById('fDirCategory').value;
let dirs=[...new Set(entries.filter(e=>e.directiveNo).map(e=>e.directiveNo))].sort();
if(fDir!=='all') dirs=dirs.filter(d=>d===fDir);
const curDir=document.getElementById('fDirNo').value;
document.getElementById('fDirNo').innerHTML='<option value="all">Все указания</option>'+dirs.map(d=>`<option value="${escAttr(d)}" ${curDir===d?'selected':''}>${esc(d)}</option>`).join('');
const cats=[...new Set(entries.map(e=>e.category).filter(Boolean))].sort();
const curCat=document.getElementById('fDirCategory').value;
document.getElementById('fDirCategory').innerHTML='<option value="all">Все категории</option>'+cats.map(c=>`<option value="${escAttr(c)}" ${curCat===c?'selected':''}>${esc(c)}</option>`).join('');
let html='';
dirs.forEach(dirNo=>{
let dirEntries=entries.filter(e=>e.directiveNo===dirNo);
if(fCat!=='all') dirEntries=dirEntries.filter(e=>e.category===fCat);
const dirBy=dirEntries[0]?.directiveBy||'';
const suspActs=[...new Set(dirEntries.filter(e=>e.suspensionAct).map(e=>e.suspensionAct))];
const planSum=dirEntries.reduce((s,e)=>s+(+e.plan||0),0);
const factSum=dirEntries.reduce((s,e)=>s+(+e.fact||0),0);
const pct=planSum?Math.round(factSum/planSum*100):0;
// Group by category
const byCategory={};
dirEntries.forEach(e=>{
const cat=e.category||'Без категории';
if(!byCategory[cat]) byCategory[cat]={entries:[],plan:0,fact:0};
byCategory[cat].entries.push(e);
byCategory[cat].plan+=(+e.plan||0);
byCategory[cat].fact+=(+e.fact||0);
});
html+=`<div class="chart-card" style="margin-bottom:18px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<div>
<h3 style="margin-bottom:2px">Указание №${esc(dirNo)}</h3>
${dirBy?`<div style="font-size:13px;color:var(--gray-500)">Оформил: ${esc(dirBy)}</div>`:''}
${suspActs.length?`<div style="font-size:12px;color:var(--amber)">Акты приостановки: ${suspActs.map(a=>esc(a)).join(', ')}</div>`:''}
</div>
<div style="text-align:right">
<div style="font-size:13px;color:var(--gray-500)">Записей: ${dirEntries.length}</div>
<div style="font-size:13px">План: <strong>${planSum}</strong> | Факт: <strong>${factSum}</strong></div>
<div style="font-size:20px;font-weight:800;color:${pct>=100?'var(--green)':pct>=70?'var(--amber)':'var(--red)'}">${pct}%</div>
</div>
</div>
<div style="font-size:13px;font-weight:600;margin-bottom:8px;color:var(--gray-500)">Категории:</div>`;
Object.entries(byCategory).forEach(([cat,data])=>{
const cpct=data.plan?Math.round(data.fact/data.plan*100):0;
html+=`<div class="bar-row">
<span class="bar-label">${esc(cat)}</span>
<div class="bar-track"><div class="bar-fill" style="width:${data.plan?Math.round(data.fact/Math.max(1,planSum)*100):0}%;background:${cpct>=100?'var(--green)':cpct>=70?'var(--amber)':'var(--red)'}"></div></div>
<span class="bar-num">П:${data.plan} Ф:${data.fact}</span>
</div>`;
data.entries.forEach(e=>{
html+=`<div style="font-size:12px;color:var(--gray-500);margin-left:220px;margin-bottom:2px;display:flex;justify-content:space-between">
<span>${esc(e.unit)}${esc(e.city)}${e.month} ${e.year}</span>
<span style="font-size:11px">План:${e.plan||0} Факт:${e.fact||0}</span>
</div>`;
});
});
html+='</div>';
});
if(!dirs.length) html='<div class="chart-card"><div class="empty">Нет указаний. Добавьте запись с № указания.</div></div>';
document.getElementById('directivesContent').innerHTML=html;
}
/* ========== ADD ========== */
function prepareAdd(){
document.getElementById('afUnit').value='';
document.getElementById('afCity').value='';
document.getElementById('afRegion').value='';
document.getElementById('afMonth').value='';
document.getElementById('afYear').value=new Date().getFullYear();
document.getElementById('afPlan').value='';
document.getElementById('afFact').value='';
document.getElementById('afDirNo').value='';
document.getElementById('afDirBy').value='';
document.getElementById('afSusp').value='';
const sel=document.getElementById('afCategory');
sel.innerHTML=CATEGORIES.map(c=>`<option value="${c}">${c}</option>`).join('');
}
function addEntry(){
const unit=document.getElementById('afUnit').value.trim();
const city=document.getElementById('afCity').value.trim();
const region=document.getElementById('afRegion').value.trim();
const month=document.getElementById('afMonth').value;
const year=document.getElementById('afYear').value;
const category=document.getElementById('afCategory').value;
const plan=parseInt(document.getElementById('afPlan').value)||0;
const fact=parseInt(document.getElementById('afFact').value)||0;
const dirNo=document.getElementById('afDirNo').value.trim();
const dirBy=document.getElementById('afDirBy').value.trim();
const susp=document.getElementById('afSusp').value.trim();
if(!unit){ alert('Укажите наименование структурного подразделения'); return; }
if(!month){ alert('Выберите месяц'); return; }
if(!year){ alert('Укажите год'); return; }
entries.push({id:genId(),unit,city,region,month,year:+year,category,plan,fact,directiveNo:dirNo,directiveBy:dirBy,suspensionAct:susp});
save('ot_entries',entries);
prepareAdd();
renderDashboard();
alert('Запись сохранена!');
}
/* ========== EDIT ========== */
function editEntry(id){
const e=entries.find(x=>x.id===id); if(!e) return;
document.getElementById('editModalContent').innerHTML=`
<h3>Редактировать запись</h3>
<div class="form-row">
<div class="form-group"><label>Подразделение</label><input id="evUnit" value="${escAttr(e.unit)}"></div>
<div class="form-group"><label>Нас. пункт</label><input id="evCity" value="${escAttr(e.city)}"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Регион</label><input id="evRegion" value="${escAttr(e.region)}"></div>
<div class="form-group"><label>Месяц</label><select id="evMonth">${MONTHS.map(m=>`<option value="${m}" ${m===e.month?'selected':''}>${m}</option>`).join('')}</select></div>
</div>
<div class="form-row">
<div class="form-group"><label>Год</label><input type="number" id="evYear" value="${e.year}"></div>
<div class="form-group"><label>Категория</label><select id="evCategory">${CATEGORIES.map(c=>`<option value="${c}" ${c===e.category?'selected':''}>${c}</option>`).join('')}</select></div>
</div>
<div class="form-row">
<div class="form-group"><label>План</label><input type="number" id="evPlan" value="${e.plan}"></div>
<div class="form-group"><label>Факт</label><input type="number" id="evFact" value="${e.fact}"></div>
</div>
<div class="form-row">
<div class="form-group"><label>№ Указания</label><input id="evDirNo" value="${escAttr(e.directiveNo||'')}"></div>
<div class="form-group"><label>Оформил</label><input id="evDirBy" value="${escAttr(e.directiveBy||'')}"></div>
</div>
<div class="form-group"><label>Акт приостановки</label><input id="evSusp" value="${escAttr(e.suspensionAct||'')}"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeEditModal()">Отмена</button>
<button class="btn btn-primary" onclick="saveEdit('${id}')">Сохранить</button>
</div>
`;
document.getElementById('editModal').style.display='flex';
}
function closeEditModal(){ document.getElementById('editModal').style.display='none' }
function saveEdit(id){
entries=entries.map(e=>e.id===id?{
...e,
unit:document.getElementById('evUnit').value.trim(),
city:document.getElementById('evCity').value.trim(),
region:document.getElementById('evRegion').value.trim(),
month:document.getElementById('evMonth').value,
year:+document.getElementById('evYear').value,
category:document.getElementById('evCategory').value,
plan:+document.getElementById('evPlan').value||0,
fact:+document.getElementById('evFact').value||0,
directiveNo:document.getElementById('evDirNo').value.trim(),
directiveBy:document.getElementById('evDirBy').value.trim(),
suspensionAct:document.getElementById('evSusp').value.trim()
}:e);
save('ot_entries',entries);
closeEditModal();
renderTable();
renderDashboard();
}
function deleteEntry(id){
if(!confirm('Удалить запись?')) return;
entries=entries.filter(e=>e.id!==id);
save('ot_entries',entries);
renderTable();
renderDashboard();
}
/* ========== FILE IMPORT ========== */
function importFile(input){
const file=input.files[0];
if(!file) return;
const ext=file.name.split('.').pop().toLowerCase();
if(ext==='xlsx'||ext==='xls'){
const reader=new FileReader();
reader.onload=function(e){
try{
const data=new Uint8Array(e.target.result);
const wb=XLSX.read(data,{type:'array'});
const sheet=wb.Sheets[wb.SheetNames[0]];
const rows=XLSX.utils.sheet_to_json(sheet,{header:1});
if(!rows.length){ alert('Файл пуст'); return; }
const headers=rows[0].map(String);
const dataRows=rows.slice(1).filter(r=>r.some(c=>c!==undefined&&c!==null&&String(c).trim()!==''));
showImportPreview(headers,dataRows);
}catch(err){
alert('Ошибка чтения Excel: '+err.message);
}
};
reader.readAsArrayBuffer(file);
} else if(ext==='docx'){
const reader=new FileReader();
reader.onload=function(e){
try{
const buf=e.target.result;
mammoth.extractRawText({arrayBuffer:buf}).then(function(result){
const text=result.value;
const lines=text.split('\n').filter(l=>l.trim());
if(!lines.length){ alert('Не удалось извлечь данные из Word'); return; }
const tabs=lines.map(l=>l.split('\t'));
const maxCols=Math.max(...tabs.map(t=>t.length));
if(maxCols<2){
alert('Word файл не содержит табличных данных. Скопируйте таблицу и вставьте в Excel, затем импортируйте.');
return;
}
const headers=tabs[0].length>=2?tabs[0]:tabs[0].map((_,i)=>'Столбец '+(i+1));
const dataRows=tabs.slice(headers.length>=2?1:0).filter(r=>r.some(c=>c.trim()));
showImportPreview(headers,dataRows);
}).catch(function(err){
alert('Ошибка чтения Word: '+err.message);
});
}catch(err){
alert('Ошибка: '+err.message);
}
};
reader.readAsArrayBuffer(file);
}
input.value='';
}
function showImportPreview(headers,dataRows){
const unitIdx=findCol(headers,['подраздел','структур','unit']);
const cityIdx=findCol(headers,['населен','пункт','город','city']);
const regionIdx=findCol(headers,['регион','област','region']);
const monthIdx=findCol(headers,['месяц','month']);
const yearIdx=findCol(headers,['год','year']);
const catIdx=findCol(headers,['категор','category']);
const planIdx=findCol(headers,['план','plan']);
const factIdx=findCol(headers,['факт','fact']);
const dirNoIdx=findCol(headers,['указан','directive','№']);
const dirByIdx=findCol(headers,['оформил','directiveby']);
const suspIdx=findCol(headers,['акт','приостан','suspension']);
let preview='<table><thead><tr><th>#</th>';
headers.forEach(h=>preview+=`<th>${esc(String(h))}</th>`);
preview+='</tr></thead><tbody>';
dataRows.slice(0,10).forEach((r,i)=>{
preview+=`<tr><td>${i+1}</td>`;
r.forEach(c=>preview+=`<td>${esc(String(c??''))}</td>`);
preview+='</tr>';
});
preview+='</tbody></table>';
document.getElementById('importModalContent').innerHTML=`
<h3>Импорт из Excel</h3>
<div class="import-info">Найдено строк: <strong>${dataRows.length}</strong>. Столбцы сопоставлены автоматически.</div>
<div class="import-info">
<div>Подразделение → столбец «${headers[unitIdx]||'—'}» ${unitIdx>=0?'✓':'✗ не найден'}</div>
<div>Нас. пункт → «${headers[cityIdx]||'—'}» ${cityIdx>=0?'✓':'✗'}</div>
<div>Регион → «${headers[regionIdx]||'—'}» ${regionIdx>=0?'✓':'✗'}</div>
<div>Месяц → «${headers[monthIdx]||'—'}» ${monthIdx>=0?'✓':'✗'}</div>
<div>Год → «${headers[yearIdx]||'—'}» ${yearIdx>=0?'✓':'✗'}</div>
<div>Категория → «${headers[catIdx]||'—'}» ${catIdx>=0?'✓':'✗'}</div>
<div>План → «${headers[planIdx]||'—'}» ${planIdx>=0?'✓':'✗'}</div>
<div>Факт → «${headers[factIdx]||'—'}» ${factIdx>=0?'✓':'✗'}</div>
<div>№ Указания → «${headers[dirNoIdx]||'—'}» ${dirNoIdx>=0?'✓':'✗'}</div>
<div>Оформил → «${headers[dirByIdx]||'—'}» ${dirByIdx>=0?'✓':'✗'}</div>
<div>Акт приостановки → «${headers[suspIdx]||'—'}» ${suspIdx>=0?'✓':'✗'}</div>
</div>
<div class="import-preview">${preview}</div>
${dataRows.length>10?`<div class="import-info">Показаны первые 10 из ${dataRows.length} строк</div>`:''}
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeImportModal()">Отмена</button>
<button class="btn btn-danger" onclick="importReplace(${JSON.stringify(headers).replace(/"/g,'&quot;')},${JSON.stringify(dataRows).replace(/"/g,'&quot;')})">Заменить все данные</button>
<button class="btn btn-primary" onclick="importAppend(${JSON.stringify(headers).replace(/"/g,'&quot;')},${JSON.stringify(dataRows).replace(/"/g,'&quot;')})">Добавить к существующим</button>
</div>
`;
document.getElementById('importModal').style.display='flex';
}
function closeImportModal(){ document.getElementById('importModal').style.display='none' }
function findCol(headers,keywords){
for(let i=0;i<headers.length;i++){
const h=headers[i].toLowerCase();
for(const kw of keywords){ if(h.includes(kw)) return i }
}
return -1;
}
function parseRows(headers,dataRows){
const unitIdx=findCol(headers,['подраздел','структур','unit']);
const cityIdx=findCol(headers,['населен','пункт','город','city']);
const regionIdx=findCol(headers,['регион','област','region']);
const monthIdx=findCol(headers,['месяц','month']);
const yearIdx=findCol(headers,['год','year']);
const catIdx=findCol(headers,['категор','category']);
const planIdx=findCol(headers,['план','plan']);
const factIdx=findCol(headers,['факт','fact']);
const dirNoIdx=findCol(headers,['указан','directive','№']);
const dirByIdx=findCol(headers,['оформил','directiveby']);
const suspIdx=findCol(headers,['акт','приостан','suspension']);
return dataRows.map(row=>({
id:genId(),
unit:String(row[unitIdx]||'').trim(),
city:String(row[cityIdx]||'').trim(),
region:String(row[regionIdx]||'').trim(),
month:normalizeMonth(String(row[monthIdx]||'').trim()),
year:parseInt(row[yearIdx])||new Date().getFullYear(),
category:String(row[catIdx]||'').trim(),
plan:parseInt(row[planIdx])||0,
fact:parseInt(row[factIdx])||0,
directiveNo:String(row[dirNoIdx]||'').trim(),
directiveBy:String(row[dirByIdx]||'').trim(),
suspensionAct:String(row[suspIdx]||'').trim()
})).filter(e=>e.unit);
}
function normalizeMonth(m){
if(!m) return '';
m=m.charAt(0).toUpperCase()+m.slice(1).toLowerCase();
if(MONTHS.includes(m)) return m;
for(const mo of MONTHS){ if(mo.toLowerCase().startsWith(m.toLowerCase())) return mo }
const num=parseInt(m);
if(num>=1&&num<=12) return MONTHS[num-1];
return m;
}
function importReplace(headers,dataRows){
entries=parseRows(headers,dataRows);
save('ot_entries',entries);
closeImportModal();
renderTable();
renderDashboard();
alert('Данные заменены! Загружено записей: '+entries.length);
}
function importAppend(headers,dataRows){
const newEntries=parseRows(headers,dataRows);
entries=[...entries,...newEntries];
save('ot_entries',entries);
closeImportModal();
renderTable();
renderDashboard();
alert('Добавлено записей: '+newEntries.length+'. Всего: '+entries.length);
}
/* ========== EXPORT ========== */
function exportExcel(){
const data=[['Подразделение','Нас. пункт','Регион','Месяц','Год','Категория','План','Факт','№ Указания','Оформил','Акт приостановки']];
entries.forEach(e=>{
data.push([e.unit,e.city,e.region,e.month,e.year,e.category,e.plan,e.fact,e.directiveNo,e.directiveBy,e.suspensionAct]);
});
const ws=XLSX.utils.aoa_to_sheet(data);
const wb=XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb,ws,'Контроли ОТ и ТБ');
XLSX.writeFile(wb,'ot_tb_control.xlsx');
}
/* ========== HELPERS ========== */
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') }
function escAttr(s){ return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;') }
/* INIT */
renderDashboard();
</script>
</body>
</html>