v3: инлайн-редактируемая таблица по всем параметрам + сбор анкет нескольких преподавателей
This commit is contained in:
parent
c09efabc98
commit
a5596cf8e1
231
app.js
231
app.js
@ -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('btnExport').addEventListener('click', exportCSV);
|
||||
document.getElementById('btnImport').addEventListener('click', ()=>document.getElementById('importFile').click());
|
||||
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('newFieldType').addEventListener('change', e=>{
|
||||
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('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=>{
|
||||
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(){
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
/* ---------- Таблица ---------- */
|
||||
/* ---------- Таблица (инлайн-редактирование) ---------- */
|
||||
function renderTable(){
|
||||
const head = document.getElementById('tableHead');
|
||||
const body = document.getElementById('tableBody');
|
||||
const empty = document.getElementById('emptyState');
|
||||
const fields = allFields();
|
||||
|
||||
// Колонки: ФИО, Степень, Категория, База, Сертификат, действия
|
||||
head.innerHTML = `
|
||||
<th>ФИО</th><th>Научная степень</th><th>Категория</th>
|
||||
<th>Клиническая база</th><th>Сертификат</th><th class="actions">Действия</th>`;
|
||||
// Заголовок: все поля + колонка действий
|
||||
head.innerHTML = fields.map(f=>`<th>${esc(f.label)}${f.required?' <span class="req">*</span>':''}</th>`).join('') + '<th class="actions"></th>';
|
||||
|
||||
const rows = filtered();
|
||||
if(rows.length===0){
|
||||
document.getElementById('teacherTable').style.display = state.teachers.length===0 ? 'none':'table';
|
||||
empty.hidden = state.teachers.length!==0;
|
||||
if(state.teachers.length!==0){
|
||||
empty.hidden=false; empty.querySelector('p').textContent='Ничего не найдено по фильтрам.';
|
||||
empty.querySelector('button').style.display='none';
|
||||
} else {
|
||||
empty.querySelector('p').textContent='Пока нет ни одного преподавателя.';
|
||||
empty.hidden=false; empty.querySelector('p').textContent='Пока нет ни одного преподавателя.';
|
||||
empty.querySelector('button').style.display='';
|
||||
}
|
||||
document.getElementById('countPill').textContent='';
|
||||
@ -194,15 +194,11 @@ function renderTable(){
|
||||
empty.hidden=true;
|
||||
|
||||
body.innerHTML = rows.map(t=>`
|
||||
<tr>
|
||||
<td class="name-cell">${esc(t.fullName||'—')}</td>
|
||||
<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>
|
||||
<tr data-id="${t.id}">
|
||||
${fields.map(f=>`<td>${cellInput(f,t)}</td>`).join('')}
|
||||
<td class="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>
|
||||
</div>
|
||||
</td>
|
||||
@ -211,6 +207,70 @@ function renderTable(){
|
||||
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(){
|
||||
return state.teachers.filter(t=>{
|
||||
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){
|
||||
const t = state.teachers.find(x=>x.id===id);
|
||||
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(){
|
||||
renderFieldList();
|
||||
@ -359,7 +336,7 @@ function addCustomField(){
|
||||
document.getElementById('newFieldOptions').value='';
|
||||
document.getElementById('newFieldOptions').hidden=true;
|
||||
renderFieldList();
|
||||
buildFilterOptions();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
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(){
|
||||
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 f = allFields().find(x=>x.key===key);
|
||||
const grid = document.getElementById('reportGrid');
|
||||
@ -466,15 +446,34 @@ function importJSON(e){
|
||||
const reader = new FileReader();
|
||||
reader.onload = ev=>{
|
||||
try{
|
||||
const data = JSON.parse(ev.target.result);
|
||||
if(!Array.isArray(data.teachers)) throw new Error('Нет массива teachers');
|
||||
if(!confirm(`Импортировать ${data.teachers.length} преподавателей? Текущие данные будут заменены.`)) return;
|
||||
state.teachers = data.teachers;
|
||||
state.customFields = data.customFields || [];
|
||||
save(); renderAll(); buildFilterOptions();
|
||||
// сбросить список полей отчёта
|
||||
document.getElementById('reportField').innerHTML='';
|
||||
alert('Импорт выполнен');
|
||||
const parsed = JSON.parse(ev.target.result);
|
||||
// Нормализация: принимаем {teachers:[...]}, [...] или один объект-преподаватель
|
||||
let incoming = [], extraFields = [];
|
||||
if(Array.isArray(parsed)) incoming = parsed;
|
||||
else if(Array.isArray(parsed.teachers)) { incoming = parsed.teachers; extraFields = parsed.customFields || []; }
|
||||
else if(parsed && typeof parsed==='object') incoming = [parsed];
|
||||
else throw new Error('Не распознан формат файла');
|
||||
incoming = incoming.filter(t=>t && typeof t==='object');
|
||||
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); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
20
index.html
20
index.html
@ -53,9 +53,10 @@
|
||||
</table>
|
||||
<div class="empty-state" id="emptyState" hidden>
|
||||
<p>Пока нет ни одного преподавателя.</p>
|
||||
<button class="btn primary" onclick="openEditor()">+ Добавить первого</button>
|
||||
<button class="btn primary" onclick="addRow()">+ Добавить первого</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="info-banner">ℹ Данные сохраняются в этом браузере. Чтобы собрать анкеты нескольких преподавателей: каждый вводит свою строку и жмёт <b>⤓</b> в конце строки → отправляет файл вам → вы жмёте <b>⤒ Импорт</b> (данные добавятся к таблице).</p>
|
||||
</section>
|
||||
|
||||
<!-- ===== Вкладка: Отчёты ===== -->
|
||||
@ -71,22 +72,6 @@
|
||||
</section>
|
||||
</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">
|
||||
@ -119,6 +104,7 @@
|
||||
</div>
|
||||
|
||||
<input type="file" id="importFile" accept="application/json" hidden>
|
||||
<div id="datalists" hidden></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
18
styles.css
18
styles.css
@ -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}
|
||||
|
||||
/* ===== Table ===== */
|
||||
.table-wrap{background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)}
|
||||
.data-table{width:100%;border-collapse:collapse;font-size:14px}
|
||||
.table-wrap{background:#fff;border:1px solid var(--border);border-radius:var(--radius);overflow:auto;box-shadow:var(--shadow)}
|
||||
.data-table{width:100%;min-width:1120px;border-collapse:collapse;font-size:13px}
|
||||
.data-table thead{background:var(--gray-50)}
|
||||
.data-table th{
|
||||
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 .actions{white-space:nowrap;text-align:right}
|
||||
.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.green{background:var(--green-50);color:var(--green)}
|
||||
.badge.amber{background:var(--amber-50);color:var(--amber)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user