247 lines
11 KiB
Plaintext
247 lines
11 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Склонение ФИО — Справки ГИА/ГВЭ</title>
|
||
<link rel="stylesheet" href="/style.css">
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>Настройки склонения ФИО</h1>
|
||
|
||
<nav class="nav">
|
||
<a href="/" class="btn">← К списку</a>
|
||
<a href="/import" class="btn btn-green">Импорт XLS</a>
|
||
<a href="/history" class="btn" style="background:#5a3e85">История</a>
|
||
<span class="user-bar"><%= user ? user.name : '' %></span>
|
||
<button id="logout-btn" class="btn btn-logout">Выйти</button>
|
||
</nav>
|
||
|
||
<div class="card">
|
||
<p style="font-size:13px;color:#666;margin-bottom:10px">
|
||
Настройте склонение фамилии, имени и отчества для справок.
|
||
Если отмечено «Не склоняется» — ФИО будет использоваться в именительном падеже.
|
||
Если введено своё склонение — оно будет использовано вместо автоматического.
|
||
</p>
|
||
|
||
<div class="form-row">
|
||
<label>Фамилия:</label>
|
||
<input type="text" id="filter-last" class="input input-sm" placeholder="Фильтр по фамилии" oninput="loadDeclensions()">
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Имя:</label>
|
||
<input type="text" id="filter-first" class="input input-sm" placeholder="Фильтр по имени" oninput="loadDeclensions()">
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Отчество:</label>
|
||
<input type="text" id="filter-patr" class="input input-sm" placeholder="Фильтр по отчеству" oninput="loadDeclensions()">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="overflow-x:auto">
|
||
<table id="declension-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Фамилия</th>
|
||
<th>Имя</th>
|
||
<th>Отчество</th>
|
||
<th>Склонённая фамилия</th>
|
||
<th style="text-align:center">Не скл.</th>
|
||
<th>Склонённое имя</th>
|
||
<th style="text-align:center">Не скл.</th>
|
||
<th>Склонённое отчество</th>
|
||
<th style="text-align:center">Не скл.</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window.__CSRF_TOKEN = '<%= csrfToken %>';
|
||
|
||
const _origFetch = window.fetch.bind(window);
|
||
window.fetch = function(input, init = {}) {
|
||
init.credentials = 'include';
|
||
const method = (init.method || 'GET').toUpperCase();
|
||
if (['POST','PUT','PATCH','DELETE'].includes(method)) {
|
||
if (!init.headers) init.headers = {};
|
||
init.headers['X-CSRF-Token'] = window.__CSRF_TOKEN || '';
|
||
}
|
||
return _origFetch(input, init);
|
||
};
|
||
|
||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||
await fetch('/api/logout', { method: 'POST' });
|
||
window.location.href = '/login';
|
||
});
|
||
|
||
function esc(s) { return (s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
function autoDeclineLast(name, gender) {
|
||
if (!name) return '';
|
||
if (gender === 'f') {
|
||
if (name.endsWith('ая')) return name.slice(0, -2) + 'ой';
|
||
if (name.endsWith('яя')) return name.slice(0, -2) + 'ей';
|
||
if (name.endsWith('а')) return name.slice(0, -1) + 'ой';
|
||
if (name.endsWith('я')) return name.slice(0, -1) + 'е';
|
||
if (name.endsWith('ова') || name.endsWith('ева') || name.endsWith('ина')) return name.slice(0, -1) + 'ой';
|
||
return name + 'ой';
|
||
}
|
||
if (name.endsWith('ов') || name.endsWith('ев') || name.endsWith('ин') || name.endsWith('ёв')) return name + 'у';
|
||
if (name.endsWith('ий') || name.endsWith('ый')) return name.slice(0, -2) + 'ому';
|
||
if (name.endsWith('ой')) return name.slice(0, -2) + 'ому';
|
||
return name + 'у';
|
||
}
|
||
|
||
function autoDeclineFirst(name, gender) {
|
||
if (!name) return '';
|
||
if (gender === 'f') {
|
||
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
|
||
return name + 'е';
|
||
}
|
||
if (name.endsWith('й') || name.endsWith('ь')) return name.slice(0, -1) + 'ю';
|
||
if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е';
|
||
return name + 'у';
|
||
}
|
||
|
||
function autoDeclinePatr(name, gender) {
|
||
if (!name) return '';
|
||
if (gender === 'f') {
|
||
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
|
||
return name + 'е';
|
||
}
|
||
if (name.endsWith('ич')) return name + 'у';
|
||
if (name.endsWith('на')) return name.slice(0, -1) + 'е';
|
||
return name + 'у';
|
||
}
|
||
|
||
function detectGender(patronymic) {
|
||
if (!patronymic) return 'm';
|
||
const last = patronymic.toLowerCase();
|
||
if (/вна|чна|нична$/.test(last)) return 'f';
|
||
return 'm';
|
||
}
|
||
|
||
async function loadDeclensions() {
|
||
const last_name = document.getElementById('filter-last').value;
|
||
const first_name = document.getElementById('filter-first').value;
|
||
const patronymic = document.getElementById('filter-patr').value;
|
||
|
||
const params = new URLSearchParams();
|
||
if (last_name) params.set('last_name', last_name);
|
||
if (first_name) params.set('first_name', first_name);
|
||
if (patronymic) params.set('patronymic', patronymic);
|
||
|
||
const url = '/api/declensions' + (params.toString() ? '?' + params.toString() : '');
|
||
const res = await fetch(url);
|
||
const rows = await res.json();
|
||
renderTable(rows);
|
||
}
|
||
|
||
function renderTable(rows) {
|
||
const tbody = document.querySelector('#declension-table tbody');
|
||
tbody.innerHTML = rows.map((r, i) => {
|
||
const gender = detectGender(r.patronymic);
|
||
const autoLast = autoDeclineLast(r.last_name, gender);
|
||
const autoFirst = autoDeclineFirst(r.first_name, gender);
|
||
const autoPatr = autoDeclinePatr(r.patronymic, gender);
|
||
|
||
const currentLast = r.no_decline_last ? r.last_name : (r.last_name_declined || autoLast);
|
||
const currentFirst = r.no_decline_first ? r.first_name : (r.first_name_declined || autoFirst);
|
||
const currentPatr = r.no_decline_patr ? r.patronymic : (r.patronymic_declined || autoPatr);
|
||
|
||
return `
|
||
<tr data-idx="${i}">
|
||
<td>${esc(r.last_name)}</td>
|
||
<td>${esc(r.first_name)}</td>
|
||
<td>${esc(r.patronymic)}</td>
|
||
<td><input type="text" class="input input-sm decl-input" data-field="last_name_declined" data-idx="${i}" value="${esc(currentLast)}" placeholder="${esc(autoLast)}"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_last" data-idx="${i}" ${r.no_decline_last ? 'checked' : ''}></td>
|
||
<td><input type="text" class="input input-sm decl-input" data-field="first_name_declined" data-idx="${i}" value="${esc(currentFirst)}" placeholder="${esc(autoFirst)}"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_first" data-idx="${i}" ${r.no_decline_first ? 'checked' : ''}></td>
|
||
<td><input type="text" class="input input-sm decl-input" data-field="patronymic_declined" data-idx="${i}" value="${esc(currentPatr)}" placeholder="${esc(autoPatr)}"></td>
|
||
<td style="text-align:center"><input type="checkbox" class="decl-cb" data-field="no_decline_patr" data-idx="${i}" ${r.no_decline_patr ? 'checked' : ''}></td>
|
||
<td><button class="btn btn-sm btn-green" onclick="saveRow(${i})">Сохранить</button><span id="msg-${i}" class="msg"></span></td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
window.__declensionRows = rows;
|
||
|
||
document.querySelectorAll('.decl-cb').forEach(cb => {
|
||
cb.addEventListener('change', function() {
|
||
const idx = this.dataset.idx;
|
||
const field = this.dataset.field;
|
||
const row = window.__declensionRows[idx];
|
||
const inputField = field.replace('no_decline_', '') + '_declined';
|
||
const inputEl = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="${inputField}"]`);
|
||
if (this.checked) {
|
||
inputEl.value = field === 'no_decline_last' ? row.last_name : (field === 'no_decline_first' ? row.first_name : row.patronymic);
|
||
inputEl.disabled = true;
|
||
} else {
|
||
inputEl.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.decl-cb:checked').forEach(cb => {
|
||
const idx = cb.dataset.idx;
|
||
const field = cb.dataset.field;
|
||
const inputField = field.replace('no_decline_', '') + '_declined';
|
||
const inputEl = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="${inputField}"]`);
|
||
if (inputEl) inputEl.disabled = true;
|
||
});
|
||
}
|
||
|
||
async function saveRow(idx) {
|
||
const row = window.__declensionRows[idx];
|
||
const lastInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="last_name_declined"]`);
|
||
const firstInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="first_name_declined"]`);
|
||
const patrInput = document.querySelector(`.decl-input[data-idx="${idx}"][data-field="patronymic_declined"]`);
|
||
const lastCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_last"]`);
|
||
const firstCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_first"]`);
|
||
const patrCb = document.querySelector(`.decl-cb[data-idx="${idx}"][data-field="no_decline_patr"]`);
|
||
|
||
const msgEl = document.getElementById(`msg-${idx}`);
|
||
|
||
const body = {
|
||
last_name: row.last_name,
|
||
first_name: row.first_name,
|
||
patronymic: row.patronymic,
|
||
last_name_declined: lastCb.checked ? '' : lastInput.value,
|
||
first_name_declined: firstCb.checked ? '' : firstInput.value,
|
||
patronymic_declined: patrCb.checked ? '' : patrInput.value,
|
||
no_decline_last: lastCb.checked,
|
||
no_decline_first: firstCb.checked,
|
||
no_decline_patr: patrCb.checked
|
||
};
|
||
|
||
try {
|
||
const res = await fetch('/api/declensions', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
msgEl.textContent = 'Сохранено';
|
||
msgEl.className = 'msg msg-ok';
|
||
loadDeclensions();
|
||
} else {
|
||
msgEl.textContent = data.error || 'Ошибка';
|
||
msgEl.className = 'msg msg-err';
|
||
}
|
||
} catch (e) {
|
||
msgEl.textContent = 'Ошибка сети';
|
||
msgEl.className = 'msg msg-err';
|
||
}
|
||
}
|
||
|
||
loadDeclensions();
|
||
</script>
|
||
</body>
|
||
</html>
|