<!DOCTYPE html>
<html lang=»ru»>
<head>
<meta charset=»UTF-8″>
<meta name=»viewport» content=»width=device-width, initial-scale=1.0″>
<title>Учет питания учеников</title>
<script src=»https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js»></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, «Segoe UI», Roboto, sans-serif;
background: #f0f4f8; color: #1a202c; padding: 20px; line-height: 1.5;
}
.container { max-width: 1200px; margin: 0 auto; }
h1 { font-size: 24px; margin-bottom: 20px; color: #2d3748; text-align: center; }
.panel {
background: #fff; border-radius: 12px; padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 20px;
}
.panel-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button {
cursor: pointer; border: none; border-radius: 8px;
padding: 10px 18px; font-size: 14px; font-weight: 600;
transition: all 0.2s; white-space: nowrap;
}
.btn-success { background: #48bb78; color: #fff; }
.btn-success:hover:not(:disabled) { background: #38a169; }
.btn-warning { background: #ed8936; color: #fff; }
.btn-warning:hover:not(:disabled) { background: #dd6b20; }
.btn-ghost { background: #edf2f7; color: #4a5568; }
.btn-ghost:hover:not(:disabled) { background: #e2e8f0; }
.btn-ghost:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-purple { background: #9f7aea; color: #fff; }
.btn-purple:hover:not(:disabled) { background: #805ad5; }
.btn-sort { background: #667eea; color: #fff; }
.btn-sort:hover:not(:disabled) { background: #5a67d8; }
.btn-submit {
padding: 12px 24px; font-size: 15px;
background: #cbd5e0; color: #718096;
box-shadow: none; transition: all 0.2s;
}
.btn-submit.ready {
background: #48bb78; color: #fff;
box-shadow: 0 4px 12px rgba(72,187,120,0.35);
}
.btn-submit.ready:hover { background: #38a169; }
.date-block { display: flex; align-items: center; gap: 10px; }
.date-block label { font-weight: 600; color: #4a5568; }
input[type=»date»], input[type=»month»] {
padding: 9px 12px; border: 2px solid #e2e8f0; border-radius: 8px;
font-size: 15px; font-family: inherit;
}
input[type=»date»]:focus, input[type=»month»]:focus { outline: none; border-color: #4299e1; }
.save-status {
font-size: 14px; padding: 6px 12px;
border-radius: 20px; font-weight: 600;
}
.save-status.dirty { color: #744210; background: #fefcbf; }
.save-status.saved { color: #22543d; background: #c6f6d5; }
.stats { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px; }
.stat {
flex: 1; min-width: 150px; background: #fff; border-radius: 12px;
padding: 15px 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); text-align: center;
}
.stat-value { font-size: 28px; font-weight: 700; color: #2d3748; }
.stat-label {
font-size: 13px; color: #718096; text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat.total .stat-value { color: #2b6cb0; }
.stat.bf .stat-value { color: #805ad5; }
.stat.ln .stat-value { color: #38a169; }
.stat.ext .stat-value { color: #dd6b20; }
table { width: 100%; border-collapse: collapse; }
th, td {
padding: 12px; text-align: left;
border-bottom: 1px solid #e2e8f0; vertical-align: middle;
}
th {
background: #f7fafc; font-size: 13px; text-transform: uppercase;
letter-spacing: 0.5px; color: #718096; font-weight: 600;
}
th.sortable { cursor: pointer; user-select: none; transition: background 0.15s; }
th.sortable:hover { background: #edf2f7; color: #4a5568; }
th .sort-icon { font-size: 11px; margin-left: 4px; opacity: 0.5; }
th .sort-icon.on { opacity: 1; color: #667eea; }
td { font-size: 15px; }
tr:hover { background: #f7fafc; }
tr.checked { background: #f0fff4; }
tr.draft-changed { background: #fefcbf; }
tr.draft-changed:hover { background: #faf5b8; }
input[type=»checkbox»] { width: 22px; height: 22px; cursor: pointer; }
input[type=»checkbox»].cb-bf { accent-color: #805ad5; }
input[type=»checkbox»].cb-ln { accent-color: #38a169; }
input[type=»checkbox»].cb-ext { accent-color: #dd6b20; }
.empty { text-align: center; padding: 40px 20px; color: #a0aec0; font-style: italic; }
.hint { font-size: 13px; color: #a0aec0; margin-top: 8px; }
.restore-banner {
display: none;
background: #fffaf0; border-left: 4px solid #ed8936;
padding: 12px 16px; border-radius: 8px;
color: #744210; font-size: 14px; margin-bottom: 16px;
}
.restore-banner.show { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.restore-banner button { padding: 6px 14px; font-size: 13px; }
.app-toast {
position: fixed; bottom: 30px; left: 50%;
transform: translateX(-50%) translateY(140%);
background: #2d3748; color: #fff;
padding: 14px 22px; border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.25);
transition: transform 0.3s ease-out;
z-index: 2000; max-width: 90vw;
font-size: 15px; font-weight: 500;
pointer-events: none;
}
.app-toast.show { transform: translateX(-50%) translateY(0); }
.app-toast.success { background: #38a169; }
.app-toast.warning { background: #dd6b20; }
.app-toast.error { background: #e53e3e; }
@media (max-width: 700px) {
th, td { padding: 8px 6px; font-size: 13px; }
h1 { font-size: 20px; }
.btn-submit { padding: 10px 16px; font-size: 14px; }
}
</style>
</head>
<body>
<div class=»container»>
<h1>🍽️ Учет питания учеников</h1>
<div class=»restore-banner» id=»restoreBanner»>
<span id=»restoreText»>Восстановлены несохранённые изменения</span>
<button class=»btn-ghost» onclick=»dismissRestoreBanner()»>Понятно</button>
</div>
<div class=»panel»>
<div class=»panel-row»>
<div class=»date-block»>
<label for=»datePicker»>Дата:</label>
<input type=»date» id=»datePicker»>
</div>
<div class=»date-block»>
<label for=»monthPicker»>Месяц:</label>
<input type=»month» id=»monthPicker»>
</div>
<button class=»btn-purple» onclick=»exportMonth()»>📆 Экспорт за месяц</button>
</div>
<div class=»panel-row» style=»margin-top: 14px;»>
<button class=»btn-submit» id=»submitBtn» onclick=»submitChanges()»>✓ Отправить</button>
<button class=»btn-ghost» id=»cancelBtn» onclick=»cancelChanges()» disabled>✕ Отменить изменения</button>
<span class=»save-status saved» id=»saveStatus»>✓ Все изменения сохранены</span>
</div>
<div class=»panel-row» style=»margin-top: 14px;»>
<button id=»sortBtn» class=»btn-sort» onclick=»cycleSort()»>🔤 А → Я</button>
<button class=»btn-ghost» onclick=»clearDay()»>🧹 Очистить день</button>
<button class=»btn-success» onclick=»exportDay()»>📊 Экспорт за день</button>
<button class=»btn-warning» onclick=»exportAll()»>📚 Экспорт за все дни</button>
</div>
<div class=»hint»>Отметьте галочки и нажмите «✓ Отправить». Черновик сохраняется в браузере и не теряется при обновлении страницы.</div>
</div>
<div class=»stats»>
<div class=»stat total»>
<div class=»stat-value» id=»statTotal»>0</div>
<div class=»stat-label»>Всего учеников</div>
</div>
<div class=»stat bf»>
<div class=»stat-value» id=»statBf»>0</div>
<div class=»stat-label»>Завтрак</div>
</div>
<div class=»stat ln»>
<div class=»stat-value» id=»statLn»>0</div>
<div class=»stat-label»>Обед</div>
</div>
<div class=»stat ext»>
<div class=»stat-value» id=»statExt»>0</div>
<div class=»stat-label»>Продленка</div>
</div>
</div>
<div class=»panel»>
<table id=»studentsTable»>
<thead>
<tr>
<th style=»width:50px;»>№</th>
<th class=»sortable» onclick=»cycleSort()»>
ФИО <span class=»sort-icon on» id=»sortIcon»>↑</span>
</th>
<th style=»width:110px; text-align:center;»>Завтрак</th>
<th style=»width:110px; text-align:center;»>Обед</th>
<th style=»width:120px; text-align:center;»>Продленка</th>
</tr>
</thead>
<tbody id=»studentsBody»></tbody>
</table>
<div class=»empty» id=»emptyMsg»>Список учеников пуст.</div>
</div>
</div>
<div class=»app-toast» id=»appToast»></div>
<script>
const STORAGE_KEY = ‘students_nutrition_v5’;
const DRAFT_KEY = ‘students_nutrition_v5_draft’;
const INITIAL_STUDENTS = [
‘Ахняпов Самир’,’Ахняпова Сальма’,’Бакиров Камиль’,’Валиуллина Ясмин’,
‘Волкова Алиса’,’Воробьева Мария’,’Габбасов Федор’,’Гатиятуллин Тамерлан’,
‘Горовенко Кирил’,’Григорьев Тимофей’,’Грицан Руслан’,’Гумеров Тимур’,
‘Кайбышев Тагир’,’Калганов Марк’,’Кардопольцева Мария’,’Лобко Анастасия’,
‘Менибаева Белла’,’Мирский Илья’,’Мияссарова Алиса’,’Моисеев Тихон’,
‘Набиева Юлия’,’Салыкин Матвей’,’Федорова Эльза’,’Шаймухаметов Давид’,
‘Шакирова Айсель’,’Шаяхметов Аман’,’Шептухина Ксения’,’Шипилов Илья’
];
let state = {
students: [],
records: {},
sortMode: ‘name-asc’
};
let draft = {
date: null,
records: {},
dirty: false
};
// ———- Хранилище ———-
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
state.students = parsed.students || [];
state.records = parsed.records || {};
const sm = parsed.sortMode;
state.sortMode = (sm === ‘name-desc’) ? ‘name-desc’ : ‘name-asc’;
const existingNames = new Set(state.students.map(s => s.name.toLowerCase()));
const missing = INITIAL_STUDENTS.filter(n => !existingNames.has(n.toLowerCase()));
if (missing.length > 0) {
const baseTs = Date.now();
missing.forEach((name, i) => {
state.students.push({
id: ‘s_’ + (baseTs + i) + ‘_’ + Math.random().toString(36).slice(2, 7),
name: name
});
});
save();
}
} else {
seedStudents();
}
} catch (e) {
console.error(‘Ошибка загрузки:’, e);
seedStudents();
}
}
function seedStudents() {
const baseTs = Date.now();
state.students = INITIAL_STUDENTS.map((name, i) => ({
id: ‘s_’ + (baseTs + i) + ‘_’ + Math.random().toString(36).slice(2, 7),
name: name
}));
state.records = {};
state.sortMode = ‘name-asc’;
save();
}
function save() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
// ———- Черновик ———-
function saveDraft() {
try {
if (!draft.date || !draft.dirty) {
localStorage.removeItem(DRAFT_KEY);
return;
}
localStorage.setItem(DRAFT_KEY, JSON.stringify({
date: draft.date,
records: draft.records,
dirty: true,
savedAt: Date.now()
}));
} catch (e) { console.error(‘Ошибка сохранения черновика:’, e); }
}
function clearDraftStorage() { localStorage.removeItem(DRAFT_KEY); }
function restoreDraftFromStorage() {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return false;
const parsed = JSON.parse(raw);
if (!parsed.date || !parsed.records) return false;
draft.date = parsed.date;
draft.records = parsed.records;
draft.dirty = true;
return true;
} catch (e) { console.error(‘Ошибка восстановления черновика:’, e); return false; }
}
function initDraft(date, keepDirty) {
if (keepDirty && draft.date === date && draft.dirty) return;
draft.date = date;
draft.records = JSON.parse(JSON.stringify(state.records[date] || {}));
draft.dirty = false;
clearDraftStorage();
}
function commitDraft(showToastMsg) {
if (!draft.dirty || !draft.date) return false;
const clean = {};
for (const id in draft.records) {
const r = draft.records[id];
if (r.breakfast || r.lunch || r.extended) clean[id] = { …r };
}
if (Object.keys(clean).length === 0) {
delete state.records[draft.date];
} else {
state.records[draft.date] = clean;
}
save();
draft.records = JSON.parse(JSON.stringify(state.records[draft.date] || {}));
draft.dirty = false;
clearDraftStorage();
if (showToastMsg) showToast(‘Данные за ‘ + fmtDateRu(draft.date) + ‘ сохранены’, ‘success’);
return true;
}
function rollbackDraft() {
draft.records = JSON.parse(JSON.stringify(state.records[draft.date] || {}));
draft.dirty = false;
clearDraftStorage();
}
// ———- Уведомления ———-
function showToast(msg, type) {
const t = document.getElementById(‘appToast’);
t.textContent = msg;
t.className = ‘app-toast ‘ + (type || ‘success’);
void t.offsetWidth;
t.classList.add(‘show’);
clearTimeout(t._timer);
t._timer = setTimeout(() => t.classList.remove(‘show’), 2500);
}
// ———- Баннер ———-
function showRestoreBanner(date) {
document.getElementById(‘restoreText’).textContent =
‘Восстановлены несохранённые изменения за ‘ + fmtDateRu(date) + ‘. Нажмите «✓ Отправить», чтобы их сохранить.’;
document.getElementById(‘restoreBanner’).classList.add(‘show’);
}
function dismissRestoreBanner() {
document.getElementById(‘restoreBanner’).classList.remove(‘show’);
}
// ———- Утилиты ———-
function todayISO() {
const d = new Date();
const local = new Date(d.getTime() — d.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 10);
}
function currentMonthISO() { return todayISO().slice(0, 7); }
function escapeHtml(str) {
return String(str).replace(/[&<>»‘]/g, c => ({
‘&’:’&’,'<‘:’<’,’>’:’>’,'»‘:’"’,»‘»:’'’
}[c]));
}
function getRec(date, studentId, ignoreDraft) {
let source;
if (!ignoreDraft && draft.date === date && draft.records) source = draft.records;
else source = state.records[date];
return (source && source[studentId]) || { breakfast: false, lunch: false, extended: false };
}
function currentDate() { return document.getElementById(‘datePicker’).value || todayISO(); }
function fmtDateRu(iso) {
const [y, m, d] = iso.split(‘-‘);
return `${d}.${m}.${y}`;
}
function monthNameRu(ym) {
const [y, m] = ym.split(‘-‘);
const names = [‘Январь’,’Февраль’,’Март’,’Апрель’,’Май’,’Июнь’,
‘Июль’,’Август’,’Сентябрь’,’Октябрь’,’Ноябрь’,’Декабрь’];
return `${names[parseInt(m, 10) — 1]} ${y}`;
}
// ———- Сортировка ———-
function getSortedStudents() {
const arr = […state.students];
if (state.sortMode === ‘name-desc’) {
arr.sort((a, b) => b.name.localeCompare(a.name, ‘ru’, { sensitivity: ‘base’ }));
} else {
arr.sort((a, b) => a.name.localeCompare(b.name, ‘ru’, { sensitivity: ‘base’ }));
}
return arr;
}
function cycleSort() {
state.sortMode = (state.sortMode === ‘name-asc’) ? ‘name-desc’ : ‘name-asc’;
save(); render();
}
function updateSortButton() {
const btn = document.getElementById(‘sortBtn’);
const icon = document.getElementById(‘sortIcon’);
if (state.sortMode === ‘name-desc’) {
btn.textContent = ‘🔤 Я → А’;
icon.textContent = ‘↓’;
icon.classList.add(‘on’);
} else {
btn.textContent = ‘🔤 А → Я’;
icon.textContent = ‘↑’;
icon.classList.add(‘on’);
}
}
// ———- Статус ———-
function updateSaveStatus() {
const el = document.getElementById(‘saveStatus’);
const submitBtn = document.getElementById(‘submitBtn’);
const cancelBtn = document.getElementById(‘cancelBtn’);
if (!el || !submitBtn) return;
if (draft.dirty) {
el.textContent = ‘⚠ Есть несохранённые изменения’;
el.className = ‘save-status dirty’;
submitBtn.classList.add(‘ready’);
if (cancelBtn) cancelBtn.disabled = false;
} else {
el.textContent = ‘✓ Все изменения сохранены’;
el.className = ‘save-status saved’;
submitBtn.classList.remove(‘ready’);
if (cancelBtn) cancelBtn.disabled = true;
}
}
// ———- Смена даты ———-
function handleDateChange() {
const newDate = document.getElementById(‘datePicker’).value;
if (draft.dirty && draft.date !== newDate) {
const saveChanges = confirm(
‘За ‘ + fmtDateRu(draft.date) + ‘ есть несохранённые изменения.\n\n’ +
‘OK — сохранить их и перейти\n’ +
‘Отмена — перейти без сохранения’
);
if (saveChanges) {
const oldDate = draft.date;
commitDraft(false);
showToast(‘Изменения за ‘ + fmtDateRu(oldDate) + ‘ сохранены’, ‘success’);
} else {
rollbackDraft();
}
}
initDraft(newDate);
render();
}
// ———- Отправка / отмена ———-
function submitChanges() {
if (!draft.dirty) {
showToast(‘Сначала отметьте галочки — сохранять пока нечего’, ‘warning’);
return;
}
commitDraft(true);
render();
}
function cancelChanges() {
if (!draft.dirty) return;
if (!confirm(‘Отменить все несохранённые изменения за ‘ + fmtDateRu(draft.date) + ‘?’)) return;
const d = draft.date;
rollbackDraft();
render();
showToast(‘Изменения за ‘ + fmtDateRu(d) + ‘ отменены’, ‘warning’);
}
// ———- Toggle (обработчик чекбоксов) ———-
function toggle(date, studentId, field, checked) {
if (draft.date !== date) initDraft(date);
if (!draft.records[studentId]) {
draft.records[studentId] = { breakfast: false, lunch: false, extended: false };
}
draft.records[studentId][field] = checked;
draft.dirty = true;
saveDraft();
render();
}
// ———- Очистка дня ———-
function clearDay() {
const date = currentDate();
if (draft.dirty) {
if (!confirm(‘Есть несохранённые изменения. Очистить день всё равно?’)) return;
} else {
if (!confirm(‘Очистить все отметки за ‘ + fmtDateRu(date) + ‘?’)) return;
}
delete state.records[date];
if (draft.date === date) {
draft.records = {};
draft.dirty = false;
}
save(); clearDraftStorage(); render();
showToast(‘Все отметки за ‘ + fmtDateRu(date) + ‘ удалены’, ‘warning’);
}
// ———- Отрисовка ———-
function render() {
const date = currentDate();
const tbody = document.getElementById(‘studentsBody’);
const empty = document.getElementById(’emptyMsg’);
const table = document.getElementById(‘studentsTable’);
tbody.innerHTML = »;
updateSortButton();
updateSaveStatus();
const sorted = getSortedStudents();
if (sorted.length === 0) {
empty.style.display = ‘block’;
table.style.display = ‘none’;
} else {
empty.style.display = ‘none’;
table.style.display = ‘table’;
sorted.forEach((s, idx) => {
const rec = getRec(date, s.id);
const savedRec = getRec(date, s.id, true);
const anyChecked = rec.breakfast || rec.lunch || rec.extended;
const hasChanges =
rec.breakfast !== savedRec.breakfast ||
rec.lunch !== savedRec.lunch ||
rec.extended !== savedRec.extended;
const tr = document.createElement(‘tr’);
if (anyChecked) tr.classList.add(‘checked’);
if (hasChanges) tr.classList.add(‘draft-changed’);
tr.innerHTML = `
<td>${idx + 1}</td>
<td>${escapeHtml(s.name)}</td>
<td style=»text-align:center;»>
<input type=»checkbox» class=»cb-bf» ${rec.breakfast ? ‘checked’ : »}
onchange=»toggle(‘${date}’, ‘${s.id}’, ‘breakfast’, this.checked)»>
</td>
<td style=»text-align:center;»>
<input type=»checkbox» class=»cb-ln» ${rec.lunch ? ‘checked’ : »}
onchange=»toggle(‘${date}’, ‘${s.id}’, ‘lunch’, this.checked)»>
</td>
<td style=»text-align:center;»>
<input type=»checkbox» class=»cb-ext» ${rec.extended ? ‘checked’ : »}
onchange=»toggle(‘${date}’, ‘${s.id}’, ‘extended’, this.checked)»>
</td>
`;
tbody.appendChild(tr);
});
}
let bf = 0, ln = 0, ext = 0;
state.students.forEach(s => {
const r = getRec(date, s.id);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
document.getElementById(‘statTotal’).textContent = state.students.length;
document.getElementById(‘statBf’).textContent = bf;
document.getElementById(‘statLn’).textContent = ln;
document.getElementById(‘statExt’).textContent = ext;
}
// ———- Экспорт ———-
function hasXLSX() { return typeof XLSX !== ‘undefined’; }
function downloadCSV(filename, rows) {
const csv = rows.map(r => r.map(c => {
const s = (c === null || c === undefined) ? » : String(c);
return `»${s.replace(/»/g, ‘»»‘)}»`;
}).join(‘;’)).join(‘\r\n’);
const blob = new Blob([‘\uFEFF’ + csv], { type: ‘text/csv;charset=utf-8;’ });
const url = URL.createObjectURL(blob);
const a = document.createElement(‘a’);
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function warnIfDirty() {
if (draft.dirty) {
return confirm(
‘Есть несохранённые изменения за ‘ + fmtDateRu(draft.date) + ‘.\n’ +
‘Экспорт возьмёт данные из сохранённой версии.\n\nПродолжить?’
);
}
return true;
}
function exportDay() {
if (!warnIfDirty()) return;
const date = currentDate();
const rows = [[‘№’, ‘ФИО’, ‘Завтрак’, ‘Обед’, ‘Продленка’]];
let bf = 0, ln = 0, ext = 0;
getSortedStudents().forEach((s, i) => {
const r = getRec(date, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
rows.push([i + 1, s.name, r.breakfast ? ‘✓’ : », r.lunch ? ‘✓’ : », r.extended ? ‘✓’ : »]);
});
rows.push([]); rows.push([», ‘ИТОГО:’, bf, ln, ext]); rows.push([]);
rows.push([‘Дата:’, fmtDateRu(date)]);
const fname = `Питание_${date}`;
if (hasXLSX()) {
const ws = XLSX.utils.aoa_to_sheet(rows);
ws[‘!cols’] = [{ wch: 5 }, { wch: 35 }, { wch: 11 }, { wch: 9 }, { wch: 13 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, ‘Питание ‘ + fmtDateRu(date));
XLSX.writeFile(wb, fname + ‘.xlsx’);
} else {
downloadCSV(fname + ‘.csv’, rows);
}
showToast(‘Экспорт за ‘ + fmtDateRu(date) + ‘ создан’, ‘success’);
}
function exportMonth() {
if (!warnIfDirty()) return;
const monthInput = document.getElementById(‘monthPicker’).value || currentMonthISO();
const [y, m] = monthInput.split(‘-‘);
const daysInMonth = new Date(parseInt(y, 10), parseInt(m, 10), 0).getDate();
const dates = [];
for (let d = 1; d <= daysInMonth; d++) {
const iso = `${y}-${String(m).padStart(2, ‘0’)}-${String(d).padStart(2, ‘0’)}`;
if (state.records[iso]) {
const hasAny = Object.values(state.records[iso]).some(
r => r.breakfast || r.lunch || r.extended
);
if (hasAny) dates.push(iso);
}
}
if (dates.length === 0) { alert(`За ${monthNameRu(monthInput)} нет отметок`); return; }
const sortedStudents = getSortedStudents();
const header1 = [‘№’, ‘ФИО’, …dates.map(d => d.slice(8, 10))];
const rows1 = [header1];
sortedStudents.forEach((s, i) => {
const row = [i + 1, s.name];
dates.forEach(d => {
const r = getRec(d, s.id, true);
const parts = [];
if (r.breakfast) parts.push(‘З’);
if (r.lunch) parts.push(‘О’);
if (r.extended) parts.push(‘П’);
row.push(parts.length ? parts.join(‘+’) : ‘—’);
});
rows1.push(row);
});
rows1.push([]);
rows1.push([», ‘ИТОГО (З/О/П):’]);
const totalsRow = [», »];
dates.forEach(d => {
let bf = 0, ln = 0, ext = 0;
state.students.forEach(s => {
const r = getRec(d, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
totalsRow.push(`${bf}/${ln}/${ext}`);
});
rows1.push(totalsRow);
rows1.push([]);
rows1.push([`Месяц: ${monthNameRu(monthInput)}`]);
rows1.push([‘Условные обозначения: З — завтрак, О — обед, П — продленка, — — нет отметок’]);
const rows2 = [[‘№’, ‘ФИО’, ‘Завтраков’, ‘Обедов’, ‘Продленок’, ‘Всего отметок’, ‘Дней с питанием’]];
sortedStudents.forEach((s, i) => {
let bf = 0, ln = 0, ext = 0;
dates.forEach(d => {
const r = getRec(d, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
rows2.push([i + 1, s.name, bf, ln, ext, bf + ln + ext, bf + ln]);
});
let tbf = 0, tln = 0, text = 0;
sortedStudents.forEach(s => {
dates.forEach(d => {
const r = getRec(d, s.id, true);
if (r.breakfast) tbf++;
if (r.lunch) tln++;
if (r.extended) text++;
});
});
rows2.push([]);
rows2.push([», ‘ИТОГО:’, tbf, tln, text, tbf + tln + text, tbf + tln]);
const rows3 = [[‘Дата’, ‘Завтраков’, ‘Обедов’, ‘Продленок’, ‘Всего’]];
dates.forEach(d => {
let bf = 0, ln = 0, ext = 0;
state.students.forEach(s => {
const r = getRec(d, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
rows3.push([fmtDateRu(d), bf, ln, ext, bf + ln + ext]);
});
rows3.push([]);
rows3.push([‘ИТОГО:’, tbf, tln, text, tbf + tln + text]);
const fname = `Питание_${monthInput}`;
if (hasXLSX()) {
const wb = XLSX.utils.book_new();
const ws1 = XLSX.utils.aoa_to_sheet(rows1);
ws1[‘!cols’] = [{ wch: 5 }, { wch: 30 }, …dates.map(() => ({ wch: 9 }))];
XLSX.utils.book_append_sheet(wb, ws1, ‘По дням месяца’);
const ws2 = XLSX.utils.aoa_to_sheet(rows2);
ws2[‘!cols’] = [{ wch: 5 }, { wch: 30 }, { wch: 12 }, { wch: 10 }, { wch: 13 }, { wch: 14 }, { wch: 17 }];
XLSX.utils.book_append_sheet(wb, ws2, ‘Сводка по ученикам’);
const ws3 = XLSX.utils.aoa_to_sheet(rows3);
ws3[‘!cols’] = [{ wch: 12 }, { wch: 12 }, { wch: 10 }, { wch: 13 }, { wch: 10 }];
XLSX.utils.book_append_sheet(wb, ws3, ‘Сводка по дням’);
XLSX.writeFile(wb, fname + ‘.xlsx’);
} else {
downloadCSV(fname + ‘.csv’, rows1);
}
showToast(‘Экспорт за ‘ + monthNameRu(monthInput) + ‘ создан’, ‘success’);
}
function exportAll() {
if (!warnIfDirty()) return;
if (state.students.length === 0) { alert(‘Нет учеников для экспорта’); return; }
const datesSet = new Set();
for (const date in state.records) {
for (const sid in state.records[date]) {
const r = state.records[date][sid];
if (r.breakfast || r.lunch || r.extended) { datesSet.add(date); break; }
}
}
const dates = Array.from(datesSet).sort();
if (dates.length === 0) { alert(‘Пока нет данных для экспорта за все дни’); return; }
const sortedStudents = getSortedStudents();
const header1 = [‘№’, ‘ФИО’, …dates.map(d => fmtDateRu(d))];
const rows1 = [header1];
sortedStudents.forEach((s, i) => {
const row = [i + 1, s.name];
dates.forEach(d => {
const r = getRec(d, s.id, true);
const parts = [];
if (r.breakfast) parts.push(‘З’);
if (r.lunch) parts.push(‘О’);
if (r.extended) parts.push(‘П’);
row.push(parts.length ? parts.join(‘+’) : ‘—’);
});
rows1.push(row);
});
rows1.push([]);
rows1.push([», ‘ИТОГО по датам (З/О/П):’]);
const totalsRow = [», »];
dates.forEach(d => {
let bf = 0, ln = 0, ext = 0;
state.students.forEach(s => {
const r = getRec(d, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
totalsRow.push(`${bf}/${ln}/${ext}`);
});
rows1.push(totalsRow);
rows1.push([]);
rows1.push([‘Условные обозначения: З — завтрак, О — обед, П — продленка, — — нет отметок’]);
const rows2 = [[‘№’, ‘ФИО’, ‘Дней с завтраком’, ‘Дней с обедом’, ‘Дней с продленкой’, ‘Всего отметок’]];
sortedStudents.forEach((s, i) => {
let bf = 0, ln = 0, ext = 0;
dates.forEach(d => {
const r = getRec(d, s.id, true);
if (r.breakfast) bf++;
if (r.lunch) ln++;
if (r.extended) ext++;
});
rows2.push([i + 1, s.name, bf, ln, ext, bf + ln + ext]);
});
const fname = `Питание_все_дни_${todayISO()}`;
if (hasXLSX()) {
const wb = XLSX.utils.book_new();
const ws1 = XLSX.utils.aoa_to_sheet(rows1);
ws1[‘!cols’] = [{ wch: 5 }, { wch: 30 }, …dates.map(() => ({ wch: 12 }))];
XLSX.utils.book_append_sheet(wb, ws1, ‘По датам’);
const ws2 = XLSX.utils.aoa_to_sheet(rows2);
ws2[‘!cols’] = [{ wch: 5 }, { wch: 30 }, { wch: 18 }, { wch: 16 }, { wch: 20 }, { wch: 15 }];
XLSX.utils.book_append_sheet(wb, ws2, ‘Сводка по ученикам’);
XLSX.writeFile(wb, fname + ‘.xlsx’);
} else {
downloadCSV(fname + ‘.csv’, rows1);
}
showToast(‘Полный экспорт создан’, ‘success’);
}
// ———- Инициализация ———-
document.getElementById(‘datePicker’).addEventListener(‘change’, handleDateChange);
document.addEventListener(‘keydown’, e => {
if ((e.ctrlKey || e.metaKey) && e.key === ‘s’) {
e.preventDefault();
submitChanges();
}
});
load();
const restored = restoreDraftFromStorage();
if (restored) {
document.getElementById(‘datePicker’).value = draft.date;
} else {
document.getElementById(‘datePicker’).value = todayISO();
initDraft(todayISO());
}
document.getElementById(‘monthPicker’).value = currentMonthISO();
render();
if (restored && draft.dirty) showRestoreBanner(draft.date);
</script>
</body>
</html>