/**
* Харчування — сторінка класного керівника.
*
* ──────────────────────────────────────────────────────────
* ЩО РОБИТЬ ЦЕЙ ФАЙЛ
*
* Малює список учнів класу на обрану дату й зберігає кожну
* відмітку одразу, без кнопки «Зберегти». Кнопка тут була б
* зайвою і навіть шкідливою: вчитель ставить одну-дві галочки
* між уроками, і забути натиснути «Зберегти» після цього —
* питання часу, а наслідок побачить кухня, а не він.
*
* ──────────────────────────────────────────────────────────
* ЧОМУ ПІДСУМОК ПРИХОДИТЬ ІЗ СЕРВЕРА
*
* Порції рахуються як «присутні мінус ті, хто не харчується».
* Рахувати це ще й тут означало б тримати ту саму формулу у
* двох місцях — а вони одного дня розійдуться, і розбіжність
* буде тихою. Тому кожна відповідь на збереження містить
* свіжий підсумок, порахований там само, де й у звіті кухні.
*/
(function () {
'use strict';
var data = window.ssMealsData;
function init() {
if (!data) { return; }
var root = document.querySelector('.ssm[data-config]');
if (!root) { return; }
var config;
try {
config = JSON.parse(root.getAttribute('data-config'));
} catch (e) {
root.innerHTML = '
Не вдалося прочитати '
+ 'налаштування сторінки.
';
return;
}
new MealsPage(root, config).start();
}
function MealsPage(root, config) {
this.root = root;
this.classes = config.classes || [];
this.classId = this.classes.length ? this.classes[0].id : 0;
this.date = data.today;
this.students = [];
this.canManage = false; // заступник чи класний керівник
this.confirm = null; // стан підтвердження дня
this.summary = null;
}
MealsPage.prototype.start = function () {
var self = this;
this.renderShell();
this.load();
this.expandWidth();
/* Ширина змінюється при повороті телефона й згортанні
бічної панелі — міряти один раз при завантаженні мало */
var timer = null;
window.addEventListener('resize', function () {
clearTimeout(timer);
timer = setTimeout(function () { self.expandWidth(); }, 150);
});
};
// ── Запити ────────────────────────────────────────────
MealsPage.prototype.request = function (path, options) {
options = options || {};
var init = {
method: options.method || 'GET',
headers: { 'X-WP-Nonce': data.nonce },
/* Cookie-автентифікація: без цього WordPress не
впізнає залогіненого користувача */
credentials: 'same-origin'
};
if (options.body) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}
return fetch(data.root + path, init).then(function (res) {
return res.json().then(function (json) {
if (!res.ok) {
throw new Error(json && json.message ? json.message : 'Помилка запиту');
}
return json;
});
});
};
MealsPage.prototype.load = function () {
var self = this;
this.setStatus('Завантаження…');
this.request('class/' + this.classId + '?date=' + encodeURIComponent(this.date))
.then(function (res) {
self.students = res.students;
self.canManage = !!res.canManage;
self.confirm = res.confirm || null;
self.history = res.history || [];
self.summary = res.summary;
self.renderList();
self.renderSummary();
self.renderConfirm();
self.renderHistory();
self.setStatus('');
})
.catch(function (err) {
self.setStatus(err.message, 'err');
});
};
// ── Розмітка ──────────────────────────────────────────
MealsPage.prototype.renderShell = function () {
var html = '
'
+ ''
+ ''
+ '
';
html += '
';
/* Вибір класу показуємо, лише якщо їх справді кілька.
Випадний список з одним пунктом — це просто зайвий
елемент, який нічого не вирішує. */
if (this.classes.length > 1) {
html += '';
}
html += '';
html += '
';
html += '';
/**
* Панель підтвердження стоїть ПІД підсумком, а не над
* таблицею.
*
* Порядок читання на сторінці — «скільки порцій →
* подати». Кнопка над списком означала б «підтвердь, а
* потім подивись, що саме підтвердив».
*/
html += '';
html += '
'
+ 'Усьому класу:'
+ ''
+ ''
+ ''
+ '
';
html += '
'
+ '
'
+ '
'
+ '
№
'
+ '
Учень
'
+ '
Відсутній
'
+ '
Не їсть
'
+ '
Постійно не їсть
'
+ '
'
+ '
'
+ ''
+ '
'
+ '
';
html += '
'
+ ''
+ ''
+ ''
+ '
';
html += ''
+ 'Додати кількох одразу зі списку'
+ '
Встав список — одне ім\'я на рядок, так само як '
+ 'у Google Таблиці чи Excel. Порожні рядки не заважають.
Хто й коли саме подавав цифри за цей день. '
+ 'Потрібно рідко — але саме тоді, коли цифра розійшлася з фактом '
+ 'і треба відновити, як усе було.
'
+ ''
+ '';
html += '';
this.root.innerHTML = html;
this.renderHeading();
this.bindShell();
};
/**
* «Клас 5-А» + день тижня українською, поверх самої дати.
*
* Раніше на сторінці не було жодного орієнтира: якщо
* вчитель відкривав сторінку, забувши, яку дату востаннє
* обирав, зрозуміти це можна було лише вдивившись у
* маленьке поле . Для класу було ще
* гірше — коли клас один, поле вибору взагалі не
* показувалось, і сторінка ніяк не підтверджувала, що це
* саме ТВІЙ клас, а не помилка.
*/
MealsPage.prototype.renderHeading = function () {
var classEl = this.root.querySelector('.ssm-heading-class');
var dateEl = this.root.querySelector('.ssm-heading-date');
if (!classEl || !dateEl) { return; }
var current = null;
for (var i = 0; i < this.classes.length; i++) {
if (this.classes[i].id === this.classId) {
current = this.classes[i];
break;
}
}
classEl.textContent = current ? classLabel(current.name) : '';
dateEl.textContent = formatDateUk(this.date);
};
/**
* «1» перетворюємо на «1 клас».
*
* Сама по собі цифра в шапці читається як номер чого
* завгодно — порції, тижня, кабінету. Слово додаємо лише
* тоді, коли його ще немає: у довіднику розкладу
* трапляються назви на кшталт «5 клас» і «9(інд)», і
* дописувати до них ще одне «клас» не можна.
*/
function classLabel(name) {
name = String(name || '').trim();
if (!name) { return ''; }
if (name.toLowerCase().indexOf('клас') !== -1) { return name; }
return name + ' клас';
}
/**
* Розтискаємо вузькі обгортки навколо блоку.
*
* Страховка до правил з :has() у meals.css — на випадок
* теми, яка кладе контент у контейнер з інлайновою
* шириною (її CSS не переб'є). Той самий код працює в
* розкладі: піднімаємось не більше ніж на вісім рівнів і
* зупиняємось на першому справді широкому предку, щоб не
* ламати верстку всієї сторінки.
*/
MealsPage.prototype.expandWidth = function () {
var root = this.root;
var chain = [];
var node = root;
var target = null;
for (var i = 0; i < 8; i++) {
node = node.parentElement;
if (!node || node === document.body || node.tagName === 'HTML') { break; }
if (node.clientWidth > root.clientWidth + 40) {
target = node;
break;
}
chain.push(node);
}
if (!target || !chain.length) { return; }
chain.forEach(function (el) {
el.style.width = '100%';
el.style.maxWidth = '100%';
});
};
MealsPage.prototype.bindShell = function () {
var self = this;
var select = this.root.querySelector('.ssm-class');
var date = this.root.querySelector('.ssm-date');
if (select) {
select.addEventListener('change', function () {
self.classId = parseInt(this.value, 10);
self.renderHeading();
self.load();
});
}
date.addEventListener('change', function () {
self.date = this.value || data.today;
self.renderHeading();
self.load();
});
this.root.querySelector('.ssm-add-btn')
.addEventListener('click', function () { self.addStudent(); });
/* Enter у полі прізвища додає учня: список заповнюють
підряд на тридцять рядків, і тягтися до кнопки після
кожного — це тридцять зайвих рухів */
this.root.querySelector('.ssm-add-name')
.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { self.addStudent(); }
});
this.root.querySelector('.ssm-bulk-btn')
.addEventListener('click', function () { self.addStudentsBulk(); });
/* Кожна кнопка надсилає ЛИШЕ свій стовпець. Раніше
«весь клас не їсть» заразом слало absent:false і тим
стирало відвідування, проставлене вранці. */
this.root.querySelector('.ssm-mass-absent').addEventListener('click', function () {
self.markClass({ absent: true, noMeal: true },
'Позначити ВЕСЬ клас відсутнім?');
});
this.root.querySelector('.ssm-mass-nomeal').addEventListener('click', function () {
self.markClass({ noMeal: true },
'Позначити, що ВЕСЬ клас сьогодні не харчується?\n\n'
+ 'Відвідування не зміниться.');
});
this.root.querySelector('.ssm-mass-clear').addEventListener('click', function () {
self.markClass({}, 'Зняти всі відмітки за цей день?');
});
};
MealsPage.prototype.renderSummary = function () {
var s = this.summary;
if (!s) { return; }
this.root.querySelector('.ssm-summary').innerHTML =
stat(s.eating, 'Порцій готувати', 'meals')
+ stat(s.present, 'Присутні')
+ stat(s.absent, 'Відсутні')
+ stat(s.total, 'Усього в класі');
};
/**
* Лічильник правок після подачі живе на сервері, і після
* кожної відмітки він міняється. Щоб панель не показувала
* позавчорашнє число, оновлюємо його на місці — без ще
* одного запиту за станом, якого сервер і так не питали.
*/
MealsPage.prototype.noteLocalChange = function () {
if (this.confirm && this.confirm.confirmed) {
this.confirm.changedAfter = (this.confirm.changedAfter || 0) + 1;
this.renderConfirm();
}
};
/**
* Панель підтвердження.
*
* ──────────────────────────────────────────────────────
* ЩО САМЕ ТУТ ПОКАЗУЄТЬСЯ І ЧОМУ
*
* Головне — не кнопка, а стан. Учитель має бачити не «чи я
* натиснув», а «чи кухня отримала мої цифри й чи вони ще
* ті самі». Тому після підтвердження тут стоїть час, а якщо
* після нього щось міняли — окремий рядок про це, з
* пропозицією подати наново.
*
* Запізнення показуємо спокійно, без червоного: воно вже
* зафіксоване в базі й видно заступнику у звіті, а лаяти
* вчителя кольором на його ж робочій сторінці — зайве.
*/
/**
* Хронологія дня для сторінки класу.
*
* Малюється в наявний контейнер, а не через повне
* перемальовування сторінки: список учнів під час цього
* не має смикатись, а вчитель міг лишити відкритою форму
* додавання.
*/
MealsPage.prototype.renderHistory = function () {
var box = this.root.querySelector('.ssm-history-body');
if (!box) { return; }
var rows = this.history || [];
if (!rows.length) {
box.innerHTML = '
За цей день ще нічого не подавали.
';
return;
}
var self = this;
var html = '
';
rows.forEach(function (row) {
var when = String(row.created_at || '');
var time = when.slice(11, 16);
var day = when.slice(0, 10);
html += '
'
+ '
' + escapeHtml(time)
+ (row.late
? ' ⚠'
: '')
/* Дата — лише коли не збігається з днем звіту:
саме так видно підтвердження, зроблене за два
дні наперед або постфактум */
+ (day !== self.date
? '' + escapeHtml(day) + ''
: '')
+ '
'
+ '
' + escapeHtml(row.who || '') + '
'
+ '
' + escapeHtml(row.details || '') + '
'
+ '
';
});
box.innerHTML = html + '
';
};
MealsPage.prototype.renderConfirm = function () {
var box = this.root.querySelector('.ssm-confirm');
if (!box) { return; }
var c = this.confirm;
if (!c) {
box.innerHTML = '';
return;
}
var deadline = c.deadline || '08:15';
var html = '';
if (!c.confirmed) {
html = '
'
+ '
'
+ 'Дані ще не подані. '
+ 'Кухня чекає цифри до ' + escapeHtml(deadline) + '.'
+ '
'
+ ''
+ '
';
} else {
/* shownAt, а не confirmedAt: у звіті стоїть остання
подача, що встигла до дедлайну, і показувати
вчителю першу означало б, що він і заступник
дивляться на різні години однієї події */
var when = String(c.shownAt || c.confirmedAt || '').slice(11, 16);
html = '
';
if (c.changedAfter) {
/* Число правок, а не просто «щось змінилось»:
одна правка — звичайна річ, вісім — привід
подати наново, поки кухня не почала готувати */
html += '
';
}
}
box.innerHTML = html;
var btn = box.querySelector('.ssm-confirm-btn');
if (btn) {
var self = this;
btn.addEventListener('click', function () {
self.confirmDay();
});
}
};
MealsPage.prototype.confirmDay = function () {
var self = this;
this.setStatus('Подання…');
this.request('confirm', {
method: 'POST',
body: { classId: this.classId, date: this.date }
}).then(function (res) {
self.confirm = res.confirm;
self.summary = res.summary;
/* Підтвердження щойно додало запис у журнал —
перечитуємо день, щоб хронологія внизу показала
його одразу, а не після оновлення сторінки */
self.renderSummary();
self.renderConfirm();
self.load();
self.setStatus('Подано', 'ok');
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
MealsPage.prototype.renderList = function () {
var body = this.root.querySelector('.ssm-tbody');
if (!this.students.length) {
body.innerHTML = '
'
+ 'Список класу порожній. Додайте учнів нижче.
';
return;
}
/**
* Вибулий учень зникає з НАСТУПНИХ днів, але лишається
* в тих, коли він ще вчився.
*
* ──────────────────────────────────────────────────
* ЧОМУ САМЕ ТАК
*
* Відсоток відвідування за місяць рахується по днях. У
* дні до вибуття дитина в класі БУЛА, і її присутність
* чи відсутність тоді — такий самий факт, як у решти.
* Прибрати її з тих днів означало б зіпсувати знаменник:
* у класі раптом стало б менше учнів, ніж було насправді.
*
* А в днях після вибуття її вже немає — і показувати її
* там нема чого.
*
* enrolled рахує сервер: умова вибуття стоїть у SQL
* підрахунків, і друга її копія тут розійшлася б із
* цифрою порцій.
*/
var html = '';
var num = 0;
for (var i = 0; i < this.students.length; i++) {
var st = this.students[i];
if (st.enrolled === false) { continue; }
num += 1;
html += this.rowHtml(st, num);
}
body.innerHTML = html;
this.bindRows();
};
MealsPage.prototype.rowHtml = function (student, number) {
var cls = 'ssm-row';
/**
* Вибулий у день, коли він ще вчився.
*
* Рядок сірий, але цілком робочий: галочки, відмітки,
* усе на місці. Сірий колір каже не «редагувати не
* можна», а «цієї дитини вже немає в класі» — і саме
* тому вона не з'явиться в завтрашньому дні.
*
* Виправляти минуле треба вміти й після вибуття: саме
* з цих днів складається відсоток відвідування за
* місяць, і помилку в них знаходять пізніше, ніж
* дитина встигає піти.
*/
if (student.leftOn) {
cls += ' ssm-row--left';
}
if (student.absent) {
cls += ' ssm-row--absent';
} else if (student.noMeal || student.noMealAlways) {
cls += ' ssm-row--nomeal';
}
var note = student.note
? '' + escapeHtml(student.note) + ''
: '';
/**
* Щоденний «Не їсть» блокується з ДВОХ різних причин:
* дитина відсутня (не може їсти фізично) або в неї
* стоїть постійна відмітка (це вже не рішення цього дня).
*
* Показуємо його позначеним у будь-якому з цих випадків —
* інакше вчитель бачив би порожній прапорець там, де
* дитина насправді в порції не рахується, і не розумів
* би, чому цифра внизу менша, ніж він очікує.
*/
var mealLocked = student.absent || student.noMealAlways;
var mealChecked = student.noMeal || mealLocked;
var lockHint = student.absent
? 'відсутній — не харчується автоматично'
: (student.noMealAlways ? 'стоїть постійна відмітка' : '');
return '
'
+ checkbox('always', student.noMealAlways,
student.name + ' — не харчується постійно', false,
'Діє на всі майбутні дати')
+ '
'
+ '
'
+ delButton()
+ '
'
+ '
';
};
MealsPage.prototype.bindRows = function () {
var self = this;
var rows = this.root.querySelectorAll('.ssm-row[data-id]');
for (var i = 0; i < rows.length; i++) {
bindRow(rows[i]);
}
function bindRow(row) {
var id = parseInt(row.getAttribute('data-id'), 10);
row.querySelector('.ssm-absent-btn').addEventListener('click', function () {
self.toggle(id, 'absent');
});
row.querySelector('.ssm-check--meal').addEventListener('change', function () {
self.toggle(id, 'noMeal');
});
row.querySelector('.ssm-check--always').addEventListener('change', function () {
self.toggleAlways(id);
});
var del = row.querySelector('.ssm-del');
if (del) {
del.addEventListener('click', function () {
var student = self.find(id);
/**
* Один хрестик, дві дії — за станом учня.
*
* У того, хто ще в класі, це вибуття: з
* завтрашнього дня він не показується,
* минуле лишається цілим.
*
* У вже вибулого — остаточне видалення.
* Тобто випадково доданого учня прибирають
* двома кліками по тому самому хрестику, і
* для цього не треба ані окремої кнопки, ані
* пояснення, коли якою користуватись.
*/
if (student && student.leftOn) {
self.purgeStudent(id);
} else {
self.removeStudent(id);
}
});
}
}
};
// ── Дії ───────────────────────────────────────────────
MealsPage.prototype.find = function (id) {
for (var i = 0; i < this.students.length; i++) {
if (this.students[i].id === id) { return this.students[i]; }
}
return null;
};
MealsPage.prototype.toggle = function (id, field) {
var self = this;
var student = this.find(id);
if (!student) { return; }
/**
* Міняємо стан у себе ОДРАЗУ, не чекаючи сервера.
*
* Пауза між натисканням і реакцією читається як «не
* спрацювало», і вчитель тисне вдруге — а це знімає
* щойно поставлену відмітку. Якщо запит впаде,
* повертаємо як було й показуємо помилку.
*/
var previous = { absent: student.absent, noMeal: student.noMeal };
if (field === 'absent') {
/**
* «Відсутній» НЕ проставляє «не їсть» у базі.
*
* На екрані зв'язок лишається той самий: у
* відсутнього прапорець «не їсть» показаний
* позначеним і заблокованим (див. rowHtml), бо
* відсутня дитина порцію не отримує в будь-якому
* разі. Але це висновок, а не збережений факт, і
* рахує його підрахунок сам — умова
* `m.absent = 0 AND m.no_meal = 1` відкидає
* відсутнього раніше, ніж дійде до «не їсть».
*
* Раніше сюди писалось student.noMeal = student.absent,
* тобто похідне значення лягало в базу. Крім того, що
* модель прямо цього не робить (див. коментар до
* ss_meals_set_class_marks), був і видимий наслідок:
* дитина з дієтою, яку відмітили відсутньою й
* наступного дня повернули, втрачала свою «не їсть»
* мовчки — її стирало зняттям «н».
*
* Тепер кожен прапорець означає рівно те, що людина
* ним сказала, і зняття одного не чіпає другий.
*/
student.absent = !previous.absent;
} else {
student.noMeal = !previous.noMeal;
}
this.renderList();
this.setStatus('Збереження…');
this.request('mark', {
method: 'POST',
body: {
studentId: id,
date: this.date,
absent: student.absent,
noMeal: student.noMeal
}
}).then(function (res) {
self.summary = res.summary;
self.renderSummary();
self.noteLocalChange();
self.setStatus('Збережено', 'ok');
}).catch(function (err) {
student.absent = previous.absent;
student.noMeal = previous.noMeal;
self.renderList();
self.setStatus(err.message, 'err');
});
};
MealsPage.prototype.addStudent = function () {
var self = this;
var nameInput = this.root.querySelector('.ssm-add-name');
var noteInput = this.root.querySelector('.ssm-add-note');
var name = nameInput.value.trim();
if (!name) {
nameInput.focus();
return;
}
this.setStatus('Додавання…');
this.request('student', {
method: 'POST',
body: {
classId: this.classId,
name: name,
note: noteInput.value.trim()
}
}).then(function () {
nameInput.value = '';
noteInput.value = '';
nameInput.focus();
self.load();
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
MealsPage.prototype.addStudentsBulk = function () {
var self = this;
var textarea = this.root.querySelector('.ssm-bulk-text');
var text = textarea.value;
if (!text.trim()) {
textarea.focus();
return;
}
this.setStatus('Додавання…');
this.request('students-bulk', {
method: 'POST',
body: { classId: this.classId, text: text }
}).then(function (res) {
textarea.value = '';
self.setStatus('Додано: ' + res.added, 'ok');
self.load();
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
/**
* Постійна відмітка «не їсть».
*
* Зберігається не як відмітка дня, а як властивість учня —
* тому йде іншим запитом (/student/{id}), ніж щоденні
* галочки (/mark). Дату все одно передаємо: сервер за нею
* перерахує підсумок, бо кількість порцій міняється одразу.
*/
MealsPage.prototype.toggleAlways = function (id) {
var self = this;
var student = this.find(id);
if (!student) { return; }
var previous = student.noMealAlways;
student.noMealAlways = !previous;
this.renderList();
this.setStatus('Збереження…');
this.request('student/' + id + '?date=' + encodeURIComponent(this.date), {
method: 'POST',
body: { noMealAlways: student.noMealAlways }
}).then(function (res) {
if (res.summary) {
self.summary = res.summary;
self.renderSummary();
}
self.setStatus('Збережено', 'ok');
}).catch(function (err) {
student.noMealAlways = previous;
self.renderList();
self.setStatus(err.message, 'err');
});
};
/**
* Однакова відмітка на весь клас.
*
* Підтвердження обов'язкове: дія перезаписує все, що вчитель
* міг уже наставити руками, і скасувати її можна лише
* повторним проходом. Один зайвий діалог тут дешевший за
* втрачені відмітки.
*/
MealsPage.prototype.markClass = function (fields, question) {
var self = this;
if (!this.students.length) { return; }
if (!window.confirm(question)) {
return;
}
var body = { classId: this.classId, date: this.date };
/* Передаємо тільки названі поля: відсутній ключ означає
для сервера «цей стовпець не чіпати» */
if (fields.hasOwnProperty('absent')) { body.absent = fields.absent; }
if (fields.hasOwnProperty('noMeal')) { body.noMeal = fields.noMeal; }
this.setStatus('Збереження…');
this.request('mark-class', {
method: 'POST',
body: body
}).then(function () {
self.setStatus('Готово', 'ok');
self.load();
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
/**
* Учень вибув.
*
* Датою вибуття стає та, що відкрита на сторінці — вчитель
* бачить її перед собою й саме про неї думає. Тому в
* підтвердженні вона названа прямо: «з 12 вересня» читається
* інакше, ніж «прибрати зі списку», і помилку в даті видно
* ДО натискання, а не через місяць у звіті.
*/
MealsPage.prototype.removeStudent = function (id) {
var self = this;
var student = this.find(id);
if (!student) { return; }
if (!window.confirm(student.name + ' вибуває з ' + humanDate(this.date) + '?\n\n'
+ 'З наступного дня його не буде в списку класу.\n'
+ 'У попередніх днях він лишиться — сірим рядком, разом зі '
+ 'своїми відмітками: без них не зійдеться відсоток '
+ 'відвідування за місяць.')) {
return;
}
this.setStatus('Збереження…');
this.request('student/' + id + '?date=' + encodeURIComponent(this.date), {
method: 'DELETE'
}).then(function (res) {
/* Сам цей день учень ще відвідував, тому лишається
в списку — але вже сірим, і завтра його не буде */
student.leftOn = res.leftOn;
self.summary = res.summary;
self.renderList();
self.renderSummary();
self.setStatus('Вибув з ' + humanDate(res.leftOn), 'ok');
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
/**
* Видалення назавжди.
*
* Підтвердження називає кількість відміток, які зникнуть.
* Не «ви впевнені?», а конкретне число: саме воно
* відрізняє помилково введеного учня (нуль) від дитини з
* місяцем історії, і саме його треба побачити перед
* незворотною дією.
*/
MealsPage.prototype.purgeStudent = function (id) {
var self = this;
var student = this.find(id);
if (!student) { return; }
/**
* Кількість відміток у підтвердженні — не формальність.
*
* Саме вона відрізняє помилково введеного учня (нуль,
* втрачати нічого) від дитини з місяцем історії, після
* видалення якої відсоток відвідування за той місяць
* порахується вже з інших даних. Одне й друге робиться
* тим самим хрестиком, тож різницю має показати текст.
*/
var what = student.marks
? 'Разом з ним зникнуть ' + student.marks + ' відміток за минулі '
+ 'дні, і відвідування за ті місяці перерахується.'
: 'Відміток у нього немає — зникне лише сам запис.';
if (!window.confirm('Видалити ' + student.name + ' НАЗАВЖДИ?\n\n'
+ what + '\n\nЦю дію неможливо скасувати.')) {
return;
}
this.setStatus('Видалення…');
this.request('student/' + id + '?purge=1&date='
+ encodeURIComponent(this.date), {
method: 'DELETE'
}).then(function (res) {
self.students = self.students.filter(function (s) { return s.id !== id; });
self.summary = res.summary;
self.renderList();
self.renderSummary();
self.setStatus('Видалено назавжди', 'ok');
}).catch(function (err) {
self.setStatus(err.message, 'err');
});
};
MealsPage.prototype.setStatus = function (text, kind) {
var el = this.root.querySelector('.ssm-status');
if (!el) { return; }
el.textContent = text || '';
el.className = 'ssm-status' + (kind ? ' ssm-status--' + kind : '');
};
// ══════════════════════════════════════════════════════
// «МОЄ ХАРЧУВАННЯ» — сторінка вчителя
// ══════════════════════════════════════════════════════
// ── Дрібниці ──────────────────────────────────────────
/**
* «Четвер, 3 вересня» замість голого 2026-09-03.
*
* ──────────────────────────────────────────────────────
* ЧОМУ ДАТА РОЗБИРАЄТЬСЯ ВРУЧНУ, А НЕ new Date(dateStr)
*
* new Date('2026-09-03') читає рядок як UTC-північ. У
* часовому поясі, де зараз раніше за UTC (а Україна влітку
* попереду UTC, тому це не наш випадок — але код мусить
* бути правильним незалежно від того, де його відкриють),
* локальний час перетворив би це на 2 вересня 23:xx
* попереднього дня — і день тижня показувався б хибний.
* new Date(рік, місяць, день) завжди будує дату в
* локальному часі, без цього зсуву.
*/
function formatDateUk(dateStr) {
var parts = dateStr.split('-');
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
var days = ['неділя', 'понеділок', 'вівторок', 'середа', 'четвер', "п'ятниця", 'субота'];
var months = ['січня', 'лютого', 'березня', 'квітня', 'травня', 'червня',
'липня', 'серпня', 'вересня', 'жовтня', 'листопада', 'грудня'];
var dayName = days[d.getDay()];
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
return dayName + ', ' + d.getDate() + ' ' + months[d.getMonth()];
}
function stat(value, label, modifier) {
return '
'
+ '' + value + '' + label + '
';
}
/**
* Справжній чекбокс, а не кнопка-пігулка.
*
* Раніше кожен перемикач був кнопкою з текстом «Відсутній» /
* «Не їсть» — на телефоні і на десктопі однаково великою.
* У таблиці на 25-30 рядків це виглядало важко й непризвично.
* Чекбокс — рідний елемент, який людина й так уміє читати з
* будь-якої таблиці чи форми, і займає рівно стільки місця,
* скільки треба.
*
* disabled — коли дитина відсутня, «не їсть» уже не її
* власна відмітка, а автоматичний наслідок (див. toggle()).
* Заблокований чекбокс явно показує: це значення саме
* тепер не редагується окремо, а не «забули поставити».
*
* aria-label замінює видимий підпис: у заголовку колонки вже
* написано «Відсутній» / «Не їсть», повторювати це в кожному
* рядку візуально не треба, а для читачів з екрана — треба,
* інакше кожен чекбокс звучав би однаково безіменно.
*/
/**
* Відвідування — літера «Н», а не прапорець.
*
* У шкільному журналі відсутність позначають саме «н», і
* вчитель читає цей стовпець автоматично, не вдумуючись.
* Прапорець тут вимагав би окремого зусилля: «галочка
* означає що — був чи не був?». Порожня клітинка = був,
* «н» = не був, як на папері.
*/
function absentToggle(absent, name) {
return '';
}
function checkbox(kind, checked, label, disabled, hint) {
return '';
}
/**
* Кнопка видалення — червоний хрест у прямокутнику.
*
* Простий хрест, розміром рівно як комірка з прапорцем
* поруч — свідомо, щоб рядок таблиці лишався компактним, а
* не мав один елемент помітно більшим за сусідні.
*/
/**
* Дата словами для підтверджень.
*
* У вікні confirm людина ухвалює незворотне рішення, і
* «2026-09-12» там читається гірше за «12 вересня»: саме
* на такій різниці й проґавлюють, що відкрито не той день.
*/
function humanDate(iso) {
var months = ['січня', 'лютого', 'березня', 'квітня', 'травня',
'червня', 'липня', 'серпня', 'вересня', 'жовтня',
'листопада', 'грудня'];
var parts = String(iso || '').split('-');
if (parts.length !== 3) { return String(iso || ''); }
var m = parseInt(parts[1], 10) - 1;
if (!months[m]) { return String(iso || ''); }
return parseInt(parts[2], 10) + ' ' + months[m];
}
function delButton() {
return '';
}
/**
* Прізвища вводить людина, і вони потрапляють у innerHTML.
* Сервер їх санітизує при збереженні, але покладатися на
* це тут не можна: у базі вже можуть лежати старі записи,
* а список приходить і з інших джерел (імпорт).
*/
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}()); Warning: Cannot modify header information - headers already sent by (output started at /home/slobshar/slobsharg.school.social/www/wp-content/plugins/school-services/modules/meals/meals.php:1) in /home/slobshar/slobsharg.school.social/www/wp-includes/sitemaps/class-wp-sitemaps-renderer.php on line 126 https://www.slobsharg.school.social/wp-sitemap-posts-post-1.xmlhttps://www.slobsharg.school.social/wp-sitemap-posts-page-1.xmlhttps://www.slobsharg.school.social/wp-sitemap-posts-e-landing-page-1.xmlhttps://www.slobsharg.school.social/wp-sitemap-posts-rael-theme-template-1.xmlhttps://www.slobsharg.school.social/wp-sitemap-taxonomies-category-1.xmlhttps://www.slobsharg.school.social/wp-sitemap-users-1.xml