v3: инлайн-редактируемая таблица по всем параметрам + сбор анкет нескольких преподавателей

This commit is contained in:
Dauren777 2026-06-26 06:22:26 +00:00
parent c09efabc98
commit a5596cf8e1
3 changed files with 134 additions and 135 deletions

231
app.js
View File

@ -98,13 +98,11 @@ function bindEvents(){
}); });
// Кнопки // Кнопки
document.getElementById('btnAdd').addEventListener('click', ()=>openEditor()); document.getElementById('btnAdd').addEventListener('click', addRow);
document.getElementById('btnFields').addEventListener('click', openFields); document.getElementById('btnFields').addEventListener('click', openFields);
document.getElementById('btnExport').addEventListener('click', exportCSV); document.getElementById('btnExport').addEventListener('click', exportCSV);
document.getElementById('btnImport').addEventListener('click', ()=>document.getElementById('importFile').click()); document.getElementById('btnImport').addEventListener('click', ()=>document.getElementById('importFile').click());
document.getElementById('importFile').addEventListener('change', importJSON); document.getElementById('importFile').addEventListener('change', importJSON);
document.getElementById('btnSave').addEventListener('click', saveEditor);
document.getElementById('btnDelete').addEventListener('click', deleteEditor);
document.getElementById('btnAddField').addEventListener('click', addCustomField); document.getElementById('btnAddField').addEventListener('click', addCustomField);
document.getElementById('newFieldType').addEventListener('change', e=>{ document.getElementById('newFieldType').addEventListener('change', e=>{
document.getElementById('newFieldOptions').hidden = e.target.value!=='select'; document.getElementById('newFieldOptions').hidden = e.target.value!=='select';
@ -118,11 +116,15 @@ function bindEvents(){
document.getElementById('filterCategory').addEventListener('change', e=>{ filters.category=e.target.value; renderTable(); }); document.getElementById('filterCategory').addEventListener('change', e=>{ filters.category=e.target.value; renderTable(); });
document.getElementById('filterBase').addEventListener('change', e=>{ filters.base=e.target.value; renderTable(); }); document.getElementById('filterBase').addEventListener('change', e=>{ filters.base=e.target.value; renderTable(); });
// Инлайн-редактирование таблицы (делегирование событий)
document.getElementById('tableBody').addEventListener('input', onCellInput);
document.getElementById('tableBody').addEventListener('change', onCellInput);
// Закрытие модалок по клику на фон // Закрытие модалок по клику на фон
document.querySelectorAll('.modal-overlay').forEach(ov=>{ document.querySelectorAll('.modal-overlay').forEach(ov=>{
ov.addEventListener('click', e=>{ if(e.target===ov) ov.hidden=true; }); ov.addEventListener('click', e=>{ if(e.target===ov) ov.hidden=true; });
}); });
document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeEditor(); closeFields(); } }); document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeFields(); } });
} }
/* ---------- Выпуски фильтров ---------- */ /* ---------- Выпуски фильтров ---------- */
@ -141,7 +143,7 @@ function uniqueValues(key){
} }
/* ---------- Рендер всего ---------- */ /* ---------- Рендер всего ---------- */
function renderAll(){ renderStats(); renderTable(); buildFilterOptions(); } function renderAll(){ renderStats(); renderDatalists(); renderTable(); buildFilterOptions(); buildReportFieldOptions(); }
/* ---------- Карточки статистики ---------- */ /* ---------- Карточки статистики ---------- */
function renderStats(){ function renderStats(){
@ -164,26 +166,24 @@ function statCard(label,value,sub,cls){
return `<div class="stat-card ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div><div class="sub">${sub||''}</div></div>`; return `<div class="stat-card ${cls||''}"><div class="label">${label}</div><div class="value">${value}</div><div class="sub">${sub||''}</div></div>`;
} }
/* ---------- Таблица ---------- */ /* ---------- Таблица (инлайн-редактирование) ---------- */
function renderTable(){ function renderTable(){
const head = document.getElementById('tableHead'); const head = document.getElementById('tableHead');
const body = document.getElementById('tableBody'); const body = document.getElementById('tableBody');
const empty = document.getElementById('emptyState'); const empty = document.getElementById('emptyState');
const fields = allFields();
// Колонки: ФИО, Степень, Категория, База, Сертификат, действия // Заголовок: все поля + колонка действий
head.innerHTML = ` head.innerHTML = fields.map(f=>`<th>${esc(f.label)}${f.required?' <span class="req">*</span>':''}</th>`).join('') + '<th class="actions"></th>';
<th>ФИО</th><th>Научная степень</th><th>Категория</th>
<th>Клиническая база</th><th>Сертификат</th><th class="actions">Действия</th>`;
const rows = filtered(); const rows = filtered();
if(rows.length===0){ if(rows.length===0){
document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table'; document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table';
empty.hidden = state.teachers.length!==0;
if(state.teachers.length!==0){ if(state.teachers.length!==0){
empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.'; empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.';
empty.querySelector('button').style.display='none'; empty.querySelector('button').style.display='none';
} else { } else {
empty.querySelector('p').textContent='Пока нет ни одного преподавателя.'; empty.hidden=false; empty.querySelector('p').textContent='Пока нет ни одного преподавателя.';
empty.querySelector('button').style.display=''; empty.querySelector('button').style.display='';
} }
document.getElementById('countPill').textContent=''; document.getElementById('countPill').textContent='';
@ -194,15 +194,11 @@ function renderTable(){
empty.hidden=true; empty.hidden=true;
body.innerHTML = rows.map(t=>` body.innerHTML = rows.map(t=>`
<tr> <tr data-id="${t.id}">
<td class="name-cell">${esc(t.fullName||'—')}</td> ${fields.map(f=>`<td>${cellInput(f,t)}</td>`).join('')}
<td>${degreeBadge(t.academicDegree)}</td>
<td>${t.category && t.category!=='Нет' ? `<span class="badge cyan">${esc(t.category)}</span>` : '<span class="badge">—</span>'}</td>
<td>${esc(t.clinicalBase||'—')}</td>
<td>${t.specialistCertificate ? `<span class="badge green">✓ есть</span>` : '<span class="badge">—</span>'}</td>
<td class="actions"> <td class="actions">
<div class="row-actions"> <div class="row-actions">
<button class="icon-btn" title="Редактировать" onclick="openEditor('${t.id}')"></button> <button class="icon-btn" title="Скачать анкету (JSON)" onclick="exportTeacher('${t.id}')"></button>
<button class="icon-btn" title="Удалить" onclick="removeTeacher('${t.id}')">🗑</button> <button class="icon-btn" title="Удалить" onclick="removeTeacher('${t.id}')">🗑</button>
</div> </div>
</td> </td>
@ -211,6 +207,70 @@ function renderTable(){
document.getElementById('countPill').textContent = `Показано: ${rows.length} из ${state.teachers.length}`; document.getElementById('countPill').textContent = `Показано: ${rows.length} из ${state.teachers.length}`;
} }
// Поле ввода прямо в ячейке таблицы
function cellInput(f, t){
const val = t[f.key] ?? '';
const name = f.key;
if(f.type==='textarea')
return `<textarea class="cell-input" name="${name}" rows="2" placeholder="—">${esc(val)}</textarea>`;
if(f.type==='date')
return `<input class="cell-input" type="date" name="${name}" value="${esc(val)}">`;
if(f.type==='number')
return `<input class="cell-input" type="number" name="${name}" value="${esc(val)}" placeholder="—">`;
if(f.type==='checkbox')
return `<input class="cell-input check" type="checkbox" name="${name}" ${val===true||val==='true'?'checked':''}>`;
if(f.type==='select')
return `<input class="cell-input" list="dl_${name}" name="${name}" value="${esc(val)}" placeholder="выберите или введите">`;
return `<input class="cell-input" type="text" name="${name}" value="${esc(val)}" placeholder="—">`;
}
// Один <datalist> на каждое поле-список (без дублей по строкам)
function renderDatalists(){
const box = document.getElementById('datalists');
box.innerHTML = allFields().filter(f=>f.type==='select')
.map(f=>`<datalist id="dl_${f.key}">${(f.options||[]).map(o=>`<option value="${esc(o)}">`).join('')}</datalist>`).join('');
}
// Автосохранение при вводе в любую ячейку
let _saveTimer=null, _statTimer=null;
function onCellInput(e){
const el = e.target;
if(!el.classList || !el.classList.contains('cell-input')) return;
const tr = el.closest('tr'); if(!tr) return;
const id = tr.dataset.id;
const key = el.name;
const t = state.teachers.find(x=>x.id===id); if(!t) return;
t[key] = (el.type==='checkbox') ? el.checked : el.value;
clearTimeout(_saveTimer); _saveTimer=setTimeout(save, 350);
clearTimeout(_statTimer); _statTimer=setTimeout(()=>{
renderStats();
if(document.getElementById('tab-reports').classList.contains('active')) renderReports();
}, 500);
}
// Добавить пустую строку-преподавателя
function addRow(){
const t = { id: uid() };
allFields().forEach(f=>{ t[f.key] = (f.type==='checkbox') ? false : ''; });
state.teachers.push(t);
// сбросить фильтры, чтобы новая строка была видна
filters = { search:'', degree:'', category:'', base:'' };
document.getElementById('search').value='';
['filterDegree','filterCategory','filterBase'].forEach(id=>document.getElementById(id).value='');
save(); buildFilterOptions(); renderTable(); renderStats();
const tr = document.querySelector(`tr[data-id="${t.id}"]`);
if(tr){ const inp = tr.querySelector('[name="fullName"]'); if(inp) inp.focus(); }
}
// Скачать анкету одного преподавателя (для отправки администратору)
window.exportTeacher = function(id){
const t = state.teachers.find(x=>x.id===id);
if(!t) return;
const data = { teachers:[{ ...t }], customFields: state.customFields };
download(JSON.stringify(data, null, 2), `anketa_${(t.fullName||'prepodavatel').replace(/\s+/g,'_')}.json`, 'application/json');
};
function filtered(){ function filtered(){
return state.teachers.filter(t=>{ return state.teachers.filter(t=>{
if(filters.degree && t.academicDegree!==filters.degree) return false; if(filters.degree && t.academicDegree!==filters.degree) return false;
@ -225,13 +285,6 @@ function filtered(){
}); });
} }
function degreeBadge(d){
if(d==='Доктор наук') return '<span class="badge amber">Доктор наук</span>';
if(d==='Кандидат наук') return '<span class="badge green">Кандидат наук</span>';
if(d && d!=='Нет') return `<span class="badge">${esc(d)}</span>`;
return '<span class="badge">нет</span>';
}
window.removeTeacher = function(id){ window.removeTeacher = function(id){
const t = state.teachers.find(x=>x.id===id); const t = state.teachers.find(x=>x.id===id);
if(!t) return; if(!t) return;
@ -241,82 +294,6 @@ window.removeTeacher = function(id){
} }
}; };
/* ---------- Редактор (модалка) ---------- */
window.openEditor = function(id){
state.editingId = id || null;
const t = id ? state.teachers.find(x=>x.id===id) : null;
document.getElementById('editorTitle').textContent = t ? 'Редактировать преподавателя' : 'Новый преподаватель';
document.getElementById('btnDelete').hidden = !t;
const form = document.getElementById('editorForm');
form.innerHTML = '<div class="form-grid">' + allFields().map(f=>fieldInput(f, t)).join('') + '</div>';
document.getElementById('editorModal').hidden=false;
};
function fieldInput(f, t){
const val = t ? (t[f.key] ?? '') : '';
const req = f.required ? '<span class="req">*</span>' : '';
const full = (f.type==='textarea') ? ' full' : '';
let control='';
if(f.type==='text' || f.type==='date' || f.type==='number'){
control = `<input class="input" name="${f.key}" type="${f.type}" value="${esc(val)}">`;
} else if(f.type==='textarea'){
control = `<textarea class="input" name="${f.key}">${esc(val)}</textarea>`;
} else if(f.type==='select'){
// Редактируемый комбобокс: можно выбрать из подсказок или ввести своё значение
control = `<input class="input" list="dl_${f.key}" name="${f.key}" value="${esc(val)}" placeholder="выберите или введите своё значение">
<datalist id="dl_${f.key}">${(f.options||[]).map(o=>`<option value="${esc(o)}">`).join('')}</datalist>`;
} else if(f.type==='checkbox'){
control = `<label class="check"><input type="checkbox" name="${f.key}" ${val===true||val==='true'?'checked':''}> Да</label>`;
}
return `<div class="form-field${full}">
<label>${esc(f.label)}${req}</label>
${control}
</div>`;
}
function closeEditor(){ document.getElementById('editorModal').hidden=true; state.editingId=null; }
function saveEditor(){
const form = document.getElementById('editorForm');
const data = {};
let firstError=null;
for(const f of allFields()){
const el = form.elements[f.key];
if(!el){ data[f.key]=''; continue; }
if(f.type==='checkbox'){
data[f.key] = el.checked;
} else {
data[f.key] = el.value.trim();
}
if(f.required && !data[f.key] && data[f.key]!==false){
if(!firstError) firstError=f.label;
}
}
if(firstError){
alert('Заполните обязательное поле: '+firstError);
return;
}
if(state.editingId){
const idx = state.teachers.findIndex(t=>t.id===state.editingId);
state.teachers[idx] = { ...state.teachers[idx], ...data };
} else {
data.id = uid();
state.teachers.push(data);
}
save(); closeEditor(); renderAll();
}
function deleteEditor(){
if(!state.editingId) return;
const t = state.teachers.find(x=>x.id===state.editingId);
if(t && confirm(`Удалить преподавателя «${t.fullName}»?`)){
state.teachers = state.teachers.filter(x=>x.id!==state.editingId);
save(); closeEditor(); renderAll();
}
}
/* ---------- Настройка полей ---------- */ /* ---------- Настройка полей ---------- */
function openFields(){ function openFields(){
renderFieldList(); renderFieldList();
@ -359,7 +336,7 @@ function addCustomField(){
document.getElementById('newFieldOptions').value=''; document.getElementById('newFieldOptions').value='';
document.getElementById('newFieldOptions').hidden=true; document.getElementById('newFieldOptions').hidden=true;
renderFieldList(); renderFieldList();
buildFilterOptions(); renderAll();
} }
window.removeField = function(key){ window.removeField = function(key){
@ -372,12 +349,15 @@ window.removeField = function(key){
}; };
/* ---------- Отчёты ---------- */ /* ---------- Отчёты ---------- */
function buildReportFieldOptions(){
const sel = document.getElementById('reportField');
const prev = sel.value;
sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>`<option value="${f.key}">${esc(f.label)}</option>`).join('');
if(prev && [...sel.options].some(o=>o.value===prev)) sel.value=prev;
}
function renderReports(){ function renderReports(){
const sel = document.getElementById('reportField'); const sel = document.getElementById('reportField');
// Заполняем список полей один раз
if(sel.options.length===0){
sel.innerHTML = allFields().filter(f=>f.type!=='textarea').map(f=>`<option value="${f.key}">${esc(f.label)}</option>`).join('');
}
const key = sel.value; const key = sel.value;
const f = allFields().find(x=>x.key===key); const f = allFields().find(x=>x.key===key);
const grid = document.getElementById('reportGrid'); const grid = document.getElementById('reportGrid');
@ -466,15 +446,34 @@ function importJSON(e){
const reader = new FileReader(); const reader = new FileReader();
reader.onload = ev=>{ reader.onload = ev=>{
try{ try{
const data = JSON.parse(ev.target.result); const parsed = JSON.parse(ev.target.result);
if(!Array.isArray(data.teachers)) throw new Error('Нет массива teachers'); // Нормализация: принимаем {teachers:[...]}, [...] или один объект-преподаватель
if(!confirm(`Импортировать ${data.teachers.length} преподавателей? Текущие данные будут заменены.`)) return; let incoming = [], extraFields = [];
state.teachers = data.teachers; if(Array.isArray(parsed)) incoming = parsed;
state.customFields = data.customFields || []; else if(Array.isArray(parsed.teachers)) { incoming = parsed.teachers; extraFields = parsed.customFields || []; }
save(); renderAll(); buildFilterOptions(); else if(parsed && typeof parsed==='object') incoming = [parsed];
// сбросить список полей отчёта else throw new Error('Не распознан формат файла');
document.getElementById('reportField').innerHTML=''; incoming = incoming.filter(t=>t && typeof t==='object');
alert('Импорт выполнен'); if(incoming.length===0) throw new Error('В файле нет данных преподавателей');
// Слияние пользовательских полей
extraFields.forEach(f=>{ if(f && f.key && !allFields().some(x=>x.key===f.key)) state.customFields.push(f); });
// Добавляем преподавателей, пропуская дубли по ФИО
const existing = new Set(state.teachers.map(t=>(t.fullName||'').trim().toLowerCase()));
let added = 0, skipped = 0;
incoming.forEach(t=>{
const name = (t.fullName||'').trim().toLowerCase();
if(name && existing.has(name)){ skipped++; return; }
const obj = { id: uid() };
allFields().forEach(f=>{ obj[f.key] = t[f.key] ?? (f.type==='checkbox'? false : ''); });
state.teachers.push(obj);
existing.add(name);
added++;
});
save(); renderAll();
alert(`Добавлено: ${added}${skipped?`, пропущено дублей по ФИО: ${skipped}`:''}.`);
}catch(err){ alert('Ошибка импорта: '+err.message); } }catch(err){ alert('Ошибка импорта: '+err.message); }
}; };
reader.readAsText(file); reader.readAsText(file);

View File

@ -53,9 +53,10 @@
</table> </table>
<div class="empty-state" id="emptyState" hidden> <div class="empty-state" id="emptyState" hidden>
<p>Пока нет ни одного преподавателя.</p> <p>Пока нет ни одного преподавателя.</p>
<button class="btn primary" onclick="openEditor()">+ Добавить первого</button> <button class="btn primary" onclick="addRow()">+ Добавить первого</button>
</div> </div>
</div> </div>
<p class="info-banner"> Данные сохраняются в этом браузере. Чтобы собрать анкеты нескольких преподавателей: каждый вводит свою строку и жмёт <b></b> в конце строки → отправляет файл вам → вы жмёте <b>⤒ Импорт</b> (данные добавятся к таблице).</p>
</section> </section>
<!-- ===== Вкладка: Отчёты ===== --> <!-- ===== Вкладка: Отчёты ===== -->
@ -71,22 +72,6 @@
</section> </section>
</main> </main>
<!-- ===== Модалка: редактор преподавателя ===== -->
<div class="modal-overlay" id="editorModal" hidden>
<div class="modal">
<div class="modal-head">
<h2 id="editorTitle">Новый преподаватель</h2>
<button class="icon-btn" onclick="closeEditor()"></button>
</div>
<form id="editorForm" class="modal-body"></form>
<div class="modal-foot">
<button class="btn outline" onclick="closeEditor()">Отмена</button>
<button class="btn danger" id="btnDelete" hidden>Удалить</button>
<button class="btn primary" id="btnSave">Сохранить</button>
</div>
</div>
</div>
<!-- ===== Модалка: настройка полей ===== --> <!-- ===== Модалка: настройка полей ===== -->
<div class="modal-overlay" id="fieldsModal" hidden> <div class="modal-overlay" id="fieldsModal" hidden>
<div class="modal"> <div class="modal">
@ -119,6 +104,7 @@
</div> </div>
<input type="file" id="importFile" accept="application/json" hidden> <input type="file" id="importFile" accept="application/json" hidden>
<div id="datalists" hidden></div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>

View File

@ -94,8 +94,8 @@ textarea.input{resize:vertical;min-height:80px;line-height:1.5}
.count-pill{margin-left:auto;font-size:13px;color:var(--gray-500);background:var(--gray-100);padding:6px 12px;border-radius:20px;font-weight:600} .count-pill{margin-left:auto;font-size:13px;color:var(--gray-500);background:var(--gray-100);padding:6px 12px;border-radius:20px;font-weight:600}
/* ===== Table ===== */ /* ===== Table ===== */
.table-wrap{background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)} .table-wrap{background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:auto;box-shadow:var(--shadow)}
.data-table{width:100%;border-collapse:collapse;font-size:14px} .data-table{width:100%;min-width:1120px;border-collapse:collapse;font-size:13px}
.data-table thead{background:var(--gray-50)} .data-table thead{background:var(--gray-50)}
.data-table th{ .data-table th{
text-align:left;padding:12px 14px;font-size:12px;font-weight:700;color:var(--gray-500); text-align:left;padding:12px 14px;font-size:12px;font-weight:700;color:var(--gray-500);
@ -107,6 +107,20 @@ textarea.input{resize:vertical;min-height:80px;line-height:1.5}
.data-table .name-cell{font-weight:700;color:var(--ink)} .data-table .name-cell{font-weight:700;color:var(--ink)}
.data-table .actions{white-space:nowrap;text-align:right} .data-table .actions{white-space:nowrap;text-align:right}
.row-actions{display:inline-flex;gap:4px} .row-actions{display:inline-flex;gap:4px}
/* инлайн-ячейки */
.cell-input{
width:100%;border:1px solid transparent;background:transparent;
font:inherit;font-size:13px;padding:6px 8px;border-radius:6px;color:var(--ink);
}
.cell-input:focus{outline:none;border-color:var(--cyan-strong);background:#fff;box-shadow:0 0 0 2px var(--cyan-50)}
.cell-input::placeholder{color:var(--gray-300)}
textarea.cell-input{resize:vertical;min-height:38px;line-height:1.4}
.cell-input.check{width:auto;cursor:pointer}
.col-degree,.col-category{min-width:150px}
.col-name{min-width:200px}
.col-edu{min-width:240px}
.info-banner{font-size:12.5px;color:var(--gray-500);background:var(--cyan-50);border:1px solid #b6eef7;border-radius:8px;padding:10px 14px;margin-top:14px;line-height:1.5}
.info-banner b{color:var(--cyan-strong)}
.badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 9px;border-radius:20px;background:var(--gray-100);color:var(--gray-700)} .badge{display:inline-block;font-size:12px;font-weight:600;padding:3px 9px;border-radius:20px;background:var(--gray-100);color:var(--gray-700)}
.badge.green{background:var(--green-50);color:var(--green)} .badge.green{background:var(--green-50);color:var(--green)}
.badge.amber{background:var(--amber-50);color:var(--amber)} .badge.amber{background:var(--amber-50);color:var(--amber)}