// ═══ Ядро: чтение/запись прогресса через data-атрибут ════════════════════════
function getProgress(widget) {
try { return JSON.parse(widget.dataset.progress || '{}'); } catch { return {}; }
}
function setProgress(widget, prog) {
widget.dataset.progress = JSON.stringify(prog);
}
async function saveProgress(widget, prog) {
setProgress(widget, prog);
const id = widget.dataset.metricId;
await apiFetch(`/api/metrics/${id}/progress`, 'POST', prog);
}
// ═══ Инициализация всех виджетов ═════════════════════════════════════════════
function initMetricWidgets() {
document.querySelectorAll('.t-metric').forEach(w => initWidget(w));
}
function initWidget(w) {
if (w._init) return;
w._init = true;
const type = w.dataset.metricType;
const canEdit = typeof CAN_EDIT !== 'undefined' ? CAN_EDIT : true;
w.querySelector('.delete-metric-btn')?.addEventListener('click', async () => {
if (!confirm('Удалить трекер?')) return;
const res = await apiFetch(`/api/metrics/${w.dataset.metricId}`, 'DELETE');
if (res.ok) w.remove();
});
if (!canEdit) return;
if (type === 'checkpoints') {
const total = w.querySelectorAll('.t-cp').length;
w.querySelectorAll('button.t-cp').forEach(btn => {
btn.addEventListener('click', async () => {
const prog = getProgress(w);
const checked = prog.checked || [];
const idx = parseInt(btn.dataset.index);
const pos = checked.indexOf(idx);
if (pos === -1) checked.push(idx); else checked.splice(pos, 1);
prog.checked = checked;
btn.classList.toggle('done', checked.includes(idx));
const done = checked.length;
const fracEl = w.querySelector('.t-cp-fraction');
if (fracEl) fracEl.firstChild.textContent = done;
const fill = w.querySelector('.t-cp-fill');
if (fill && total) fill.style.width = Math.round(done / total * 100) + '%';
await saveProgress(w, prog);
});
});
}
if (type === 'progress_bar') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.update-progress-btn')?.addEventListener('click', async () => {
const input = w.querySelector('.progress-input');
const val = parseFloat(input.value) || 0;
const mx = parseFloat(cfg.max || 100);
const pct = Math.min(100, Math.round(val / mx * 100));
const ring = w.querySelector('.t-pb-ring-fill');
if (ring) ring.style.strokeDashoffset = ((100 - pct) / 100 * 213.6).toFixed(1);
const pctEl = w.querySelector('.t-pb-pct');
if (pctEl) pctEl.textContent = pct + '%';
const valEl = w.querySelector('.t-pb-val');
if (valEl) valEl.firstChild.textContent = val;
await saveProgress(w, { current: val });
});
}
if (type === 'counter') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
const valEl = w.querySelector('.t-counter-val');
const step = parseFloat(cfg.step) || 1;
const update = async delta => {
const prog = getProgress(w);
const max = cfg.max ? parseFloat(cfg.max) : Infinity;
const raw = (prog.current || 0) + delta;
const next = Math.max(0, Math.min(max, Math.round(raw * 1e9) / 1e9));
prog.current = next;
valEl.textContent = next;
if (cfg.max) {
const bar = w.querySelector('.t-counter-bar-fill');
if (bar) bar.style.width = Math.min(100, Math.round(next / parseFloat(cfg.max) * 100)) + '%';
}
await saveProgress(w, prog);
};
w.querySelector('.counter-dec')?.addEventListener('click', () => update(-step));
w.querySelector('.counter-inc')?.addEventListener('click', () => update(+step));
}
if (type === 'stages') {
w.querySelectorAll('.stage-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index);
w.querySelectorAll('.stage-btn').forEach((b, i) => {
b.classList.toggle('done', i < idx);
b.classList.toggle('active', i === idx);
if (i > idx) b.classList.remove('done', 'active');
});
w.querySelectorAll('.t-stage-conn').forEach((c, i) => {
c.classList.toggle('done', i < idx);
});
await saveProgress(w, { current_stage: idx });
});
});
}
if (type === 'checklist') {
w.querySelectorAll('.checklist-item').forEach(cb => {
cb.addEventListener('change', async () => {
const prog = getProgress(w);
const checked = prog.checked || [];
const idx = parseInt(cb.dataset.index);
const pos = checked.indexOf(idx);
if (cb.checked && pos === -1) checked.push(idx);
if (!cb.checked && pos !== -1) checked.splice(pos, 1);
prog.checked = checked;
cb.closest('.t-cl-item').classList.toggle('checked', cb.checked);
await saveProgress(w, prog);
});
});
}
if (type === 'attendance') {
w.querySelector('.add-att-btn')?.addEventListener('click', async () => {
const dateVal = w.querySelector('.att-date').value;
const status = w.querySelector('.att-status').value;
if (!dateVal) return;
const prog = getProgress(w);
const records = prog.records || [];
records.push({ date: dateVal, status });
prog.records = records;
// Добавить точку в историю
const dot = document.createElement('span');
dot.className = `t-att-dot t-att-dot--${status}`;
dot.title = dateVal;
w.querySelector('.t-att-dots')?.appendChild(dot);
// Обновить счётчик нужного стата
const statMap = { present: 't-att-stat--present', absent: 't-att-stat--absent', excused: 't-att-stat--excused' };
const statEl = w.querySelector(`.${statMap[status]} .t-att-num`);
if (statEl) statEl.textContent = parseInt(statEl.textContent || '0') + 1;
w.querySelector('.att-date').value = '';
await saveProgress(w, prog);
});
}
if (type === 'grades') {
w.querySelector('.save-grades-btn')?.addEventListener('click', async () => {
const prog = getProgress(w);
const grades = {};
w.querySelectorAll('.grade-input').forEach(inp => {
if (inp.value !== '') grades[inp.dataset.work] = parseFloat(inp.value);
});
prog.grades = grades;
await saveProgress(w, prog);
flashSaved(w.querySelector('.save-grades-btn'));
});
}
if (type === 'deadlines') {
w.querySelectorAll('.deadline-item').forEach(cb => {
cb.addEventListener('change', async () => {
const prog = getProgress(w);
const completed = prog.completed || [];
const idx = parseInt(cb.dataset.index);
const pos = completed.indexOf(idx);
if (cb.checked && pos === -1) completed.push(idx);
if (!cb.checked && pos !== -1) completed.splice(pos, 1);
prog.completed = completed;
cb.closest('.t-dl-item').classList.toggle('done', cb.checked);
await saveProgress(w, prog);
});
});
}
if (type === 'streak') {
w.querySelector('.mark-today-btn')?.addEventListener('click', async () => {
const today = new Date().toISOString().split('T')[0];
const prog = getProgress(w);
const records = prog.records || [];
if (records.includes(today)) return;
records.push(today);
prog.records = records;
w.querySelector('.t-streak-num').textContent = records.length;
w.querySelector('.mark-today-btn').disabled = true;
await saveProgress(w, prog);
});
}
if (type === 'effort_hours') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.add-effort-btn')?.addEventListener('click', async () => {
const add = parseFloat(w.querySelector('.effort-input').value);
if (!add || add <= 0) return;
const prog = getProgress(w);
const total = +(prog.hours || 0) + add;
prog.hours = total;
const numEl = w.querySelector('.t-ef-num');
if (numEl) numEl.childNodes[0].textContent = total;
const goal = parseFloat(cfg.goal_hours);
if (goal) {
const pct = Math.min(100, Math.round(total / goal * 100));
const bar = w.querySelector('.t-ef-bar-fill');
if (bar) bar.style.width = pct + '%';
const pctEl = w.querySelector('.t-ef-bar-pct');
if (pctEl) pctEl.textContent = pct + '%';
}
w.querySelector('.effort-input').value = '';
await saveProgress(w, prog);
});
}
if (type === 'rating_history') {
const cfg = (() => { try { return JSON.parse(w.dataset.config || '{}'); } catch { return {}; } })();
w.querySelector('.add-rating-btn')?.addEventListener('click', async () => {
const dateVal = w.querySelector('.rating-date').value;
const grade = parseFloat(w.querySelector('.rating-grade').value);
if (!dateVal || isNaN(grade)) return;
const prog = getProgress(w);
const entries = prog.entries || [];
entries.push({ date: dateVal, grade });
prog.entries = entries;
const scale = cfg.scale || 5;
const list = w.querySelector('.t-rh-list');
const pct = Math.round(grade / scale * 100);
const row = document.createElement('div');
row.className = 't-rh-row';
row.innerHTML = `${dateVal}`
+ `
`
+ `${grade}`;
// Удалить «Нет записей» если было
list.querySelector('.t-metric-empty')?.remove();
list.prepend(row);
// Обновить summary — последняя оценка и среднее
const avg = (entries.reduce((s, e) => s + e.grade, 0) / entries.length).toFixed(1);
['.t-rh-last-val', '.t-rh-big-val'].forEach(sel => {
const el = w.querySelector(sel);
if (el) el.textContent = grade;
});
['.t-rh-avg', '.t-rh-big-avg'].forEach(sel => {
const el = w.querySelector(sel);
if (el) el.textContent = el.textContent.replace(/[\d.]+/, avg);
});
w.querySelector('.rating-date').value = '';
w.querySelector('.rating-grade').value = '';
await saveProgress(w, prog);
});
}
}
// ═══ Форма добавления трекера ════════════════════════════════════════════════
const METRIC_SCHEMA = {
checkpoints: [{ name: 'total', label: 'Количество точек', type: 'number', ph: '10' },
{ name: 'labels', label: 'Подписи через запятую (опц.)', type: 'text', ph: 'КТ1,КТ2' }],
progress_bar: [{ name: 'max', label: 'Максимум', type: 'number', ph: '100' },
{ name: 'unit', label: 'Единица', type: 'text', ph: 'баллов' },
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' }],
counter: [{ name: 'max', label: 'Максимум (0 = ∞)', type: 'number', ph: '0' },
{ name: 'step', label: 'Шаг', type: 'number', ph: '1' },
{ name: 'unit', label: 'Единица', type: 'text', ph: 'раз' }],
stages: [{ name: 'stages', label: 'Этапы через запятую', type: 'text', ph: 'Старт,В работе,Сдано' }],
checklist: [{ name: 'items', label: 'Пункты, каждый с новой строки', type: 'textarea', ph: 'Пункт 1\nПункт 2' }],
attendance: [],
grades: [{ name: 'works', label: 'Работы: имя=вес, через запятую', type: 'text', ph: 'ЛР1=1,ЛР2=2' },
{ name: 'max_grade', label: 'Макс. балл', type: 'number', ph: '5' }],
deadlines: [{ name: 'items', label: 'Дедлайны: имя|ГГГГ-ММ-ДД, по строке', type: 'textarea', ph: 'Курсовая|2026-05-20' }],
streak: [],
effort_hours: [{ name: 'goal_hours', label: 'Цель (часов)', type: 'number', ph: '40' }],
rating_history: [{ name: 'scale', label: 'Шкала (макс. балл)', type: 'number', ph: '5' }],
};
function buildConfig(type, form) {
const schema = METRIC_SCHEMA[type] || [];
const cfg = {};
for (const f of schema) {
const el = form.querySelector(`[data-cfg="${f.name}"]`);
const val = el?.value.trim();
if (!val) continue;
if (type === 'checkpoints' && f.name === 'labels') { cfg.labels = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'stages' && f.name === 'stages') { cfg.stages = val.split(',').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'checklist' && f.name === 'items') { cfg.items = val.split('\n').map(s => s.trim()).filter(Boolean); continue; }
if (type === 'grades' && f.name === 'works') {
cfg.works = val.split(',').map(s => { const [n, w] = s.split('=').map(x => x.trim()); return { name: n, weight: parseFloat(w) || 1 }; });
continue;
}
if (type === 'deadlines' && f.name === 'items') {
cfg.items = val.split('\n').map(s => { const [n, d] = s.split('|').map(x => x.trim()); return { name: n, date: d || '' }; }).filter(x => x.name);
continue;
}
cfg[f.name] = f.type === 'number' ? parseFloat(val) || 0 : val;
}
return cfg;
}
function renderConfigFields(type, area) {
area.innerHTML = '';
for (const f of METRIC_SCHEMA[type] || []) {
const wrap = document.createElement('div');
wrap.className = 't-field';
const lbl = document.createElement('label');
lbl.className = 't-label';
lbl.textContent = f.label;
lbl.htmlFor = `cfg-${f.name}`;
wrap.appendChild(lbl);
let el;
if (f.type === 'textarea') {
el = document.createElement('textarea');
el.rows = 3;
el.className = 't-input t-textarea';
} else {
el = document.createElement('input');
el.type = f.type;
el.className = 't-input t-input-sm';
}
el.placeholder = f.ph || '';
el.dataset.cfg = f.name;
el.id = `cfg-${f.name}`;
wrap.appendChild(el);
area.appendChild(wrap);
}
}
function initAddMetric() {
document.querySelectorAll('.add-metric-section').forEach(section => {
const sid = section.dataset.schedule;
const subject = section.dataset.subject;
const slotId = section.dataset.slot;
const container = document.getElementById(`metrics-${slotId}`);
const aiForm = section.querySelector('.ai-metric-form');
const manForm = section.querySelector('.manual-metric-form');
section.querySelector('.ai-metric-btn')?.addEventListener('click', () => {
aiForm.classList.toggle('open');
manForm.classList.remove('open');
});
section.querySelector('.manual-metric-btn')?.addEventListener('click', () => {
manForm.classList.toggle('open');
aiForm.classList.remove('open');
});
const typeSelect = section.querySelector('.metric-type-select');
const configArea = section.querySelector('.metric-config-area');
typeSelect?.addEventListener('change', () => renderConfigFields(typeSelect.value, configArea));
// AI
section.querySelector('.ai-prompt-submit')?.addEventListener('click', async () => {
const prompt = section.querySelector('.ai-prompt-input').value.trim();
if (!prompt) return;
const submitBtn = section.querySelector('.ai-prompt-submit');
submitBtn.innerHTML = '';
submitBtn.disabled = true;
const res = await apiFetch(
`/schedules/${sid}/subjects/${enc(subject)}/metrics/ai`, 'POST', { prompt }
);
submitBtn.innerHTML = '';
submitBtn.disabled = false;
if (res.ok && container) {
container.insertAdjacentHTML('beforeend', res.html);
const newEl = container.lastElementChild;
initWidget(newEl);
gridRegisterNew(newEl, container);
aiForm.classList.remove('open');
section.querySelector('.ai-prompt-input').value = '';
} else alert(res.error || 'Ошибка');
});
// Вручную
section.querySelector('.manual-metric-submit')?.addEventListener('click', async () => {
const type = typeSelect?.value;
const label = section.querySelector('.metric-label-input').value.trim();
if (!type) { alert('Выберите тип'); return; }
const cfg = buildConfig(type, section);
const res = await apiFetch(
`/schedules/${sid}/subjects/${enc(subject)}/metrics`, 'POST',
{ type, label, config: cfg }
);
if (res.ok && container) {
container.insertAdjacentHTML('beforeend', res.html);
const newEl = container.lastElementChild;
initWidget(newEl);
gridRegisterNew(newEl, container);
manForm.classList.remove('open');
section.querySelector('.metric-label-input').value = '';
if (typeSelect) typeSelect.value = '';
configArea.innerHTML = '';
} else alert(res.error || 'Ошибка');
});
});
}
// ═══ Grid-редактор: размер и перетаскивание трекеров ═════════════════════════
const GRID_COLS = 3; // колонки (совпадает с --mg-cols в CSS)
const GRID_MAX_W = 3; // макс. ширина в колонках
const GRID_MAX_H = 4; // макс. высота в полустроках (4 = 2 строки)
// Применить размерные классы gw-N / gh-N (N = полустроки для высоты)
function applyGridSize(el) {
const w = Math.max(1, Math.min(GRID_MAX_W, parseInt(el.dataset.gridW) || 1));
const h = Math.max(1, Math.min(GRID_MAX_H, parseInt(el.dataset.gridH) || 2));
el.classList.remove('gw-2', 'gw-3', 'gh-1', 'gh-2', 'gh-3', 'gh-4');
if (w > 1) el.classList.add(`gw-${w}`);
el.classList.add(`gh-${h}`);
}
// ─── Сохранение позиции/размера в БД ─────────────────────────────────────────
async function saveLayout(metricId, col, row, w, h) {
await apiFetch(`/api/metrics/${metricId}/layout`, 'POST',
{ grid_col: col, grid_row: row, grid_w: w, grid_h: h });
}
// ─── Resize (правый нижний угол) ──────────────────────────────────────────────
function initResize(el, grid) {
const handle = el.querySelector('.t-metric-resize');
if (!handle) return;
handle.addEventListener('pointerdown', e => {
if (!grid.classList.contains('drag-mode')) return;
e.preventDefault();
e.stopPropagation();
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = parseInt(el.dataset.gridW) || 1;
const startH = parseInt(el.dataset.gridH) || 2;
// Ширина одной колонки; высота одной полустроки из CSS-переменной
const gap = parseFloat(getComputedStyle(grid).getPropertyValue('--mg-gap')) || 12;
const colW = (grid.offsetWidth - gap * (GRID_COLS - 1)) / GRID_COLS;
const rowHpx = parseFloat(getComputedStyle(grid).getPropertyValue('--row-h')) || 120;
function onMove(ev) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const col = parseInt(el.dataset.gridCol) || 0;
const maxW = Math.min(GRID_MAX_W, GRID_COLS - col);
const newW = Math.max(1, Math.min(maxW, Math.round(startW + dx / (colW + gap))));
const newH = Math.max(1, Math.min(GRID_MAX_H, Math.round(startH + dy / (rowHpx + gap))));
el.dataset.gridW = newW;
el.dataset.gridH = newH;
applyGridSize(el);
}
async function onUp() {
handle.removeEventListener('pointermove', onMove);
handle.removeEventListener('pointerup', onUp);
const mid = el.dataset.metricId;
const col = parseInt(el.dataset.gridCol) || 0;
const row = parseInt(el.dataset.gridRow) || 0;
const w = parseInt(el.dataset.gridW) || 1;
const h = parseInt(el.dataset.gridH) || 2;
await saveLayout(mid, col, row, w, h);
}
handle.addEventListener('pointermove', onMove);
handle.addEventListener('pointerup', onUp);
});
}
// ─── Drag-and-drop ────────────────────────────────────────────────────────────
function initDrag(grid) {
let dragged = null;
const items = () => [...grid.querySelectorAll(':scope > .t-metric')];
// Сдвиг элементов вправо/вниз, освобождая место перед target
function shiftBefore(target) {
if (!dragged || target === dragged) return;
const all = items();
const fromIdx = all.indexOf(dragged);
const toIdx = all.indexOf(target);
if (fromIdx === -1 || toIdx === -1) return;
if (fromIdx < toIdx) {
target.after(dragged);
} else {
target.before(dragged);
}
}
grid.addEventListener('dragstart', e => {
const metric = e.target.closest('.t-metric');
if (!metric || !grid.classList.contains('drag-mode')) { e.preventDefault(); return; }
dragged = metric;
metric.classList.add('drag-ghost');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', metric.dataset.metricId);
});
grid.addEventListener('dragend', e => {
const metric = e.target.closest('.t-metric');
if (!metric) return;
metric.classList.remove('drag-ghost');
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
dragged = null;
// Сохранить новый порядок: обновляем grid_row / grid_col по позиции в DOM
persistOrder(grid);
});
grid.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('.t-metric');
if (!target || target === dragged) return;
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
target.classList.add('drag-over');
shiftBefore(target);
});
grid.addEventListener('drop', e => {
e.preventDefault();
grid.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
}
// Пересчитать col/row по DOM-порядку и сохранить в БД одним батч-запросом
async function persistOrder(grid) {
let col = 0, row = 0, rowMaxH = 2;
const items = [];
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
const w = parseInt(el.dataset.gridW) || 1;
const h = parseInt(el.dataset.gridH) || 2;
if (col + w > GRID_COLS) {
col = 0;
row += rowMaxH;
rowMaxH = 2;
}
el.dataset.gridCol = col;
el.dataset.gridRow = row;
rowMaxH = Math.max(rowMaxH, h);
col += w;
items.push({ id: el.dataset.metricId, grid_col: col - w, grid_row: row, grid_w: w, grid_h: h });
});
await apiFetch('/api/metrics/layout_batch', 'POST', { items });
}
// ─── Переход к трекеру на дашборде с подсветкой ───────────────────────────────
function initMetricLinks() {
document.querySelectorAll('.t-slot-metric-link').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const mid = link.dataset.metricId;
const target = document.getElementById(`metric-${mid}`);
if (!target) return;
// Закрыть слот если открыт
const detail = link.closest('.t-slot-detail');
if (detail) {
const slot = detail.previousElementSibling;
if (slot?.classList.contains('open')) {
slot.classList.remove('open');
detail.style.display = 'none';
}
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.remove('highlight');
// Перезапуск анимации через reflow
void target.offsetWidth;
target.classList.add('highlight');
target.addEventListener('animationend', () => target.classList.remove('highlight'), { once: true });
});
});
}
// ─── Кнопки включения режима редактирования сетки ────────────────────────────
function initGridEditButtons() {
document.querySelectorAll('.t-grid-edit-btn').forEach(btn => {
const gridId = btn.dataset.grid;
const grid = document.getElementById(gridId);
if (!grid) return;
btn.addEventListener('click', () => {
const active = grid.classList.toggle('drag-mode');
btn.classList.toggle('active', active);
// draggable выставляем/снимаем на всех метриках
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
el.draggable = active;
});
});
});
}
// ─── Добавить resize-ручку к элементу ────────────────────────────────────────
function addResizeHandle(el) {
if (el.querySelector('.t-metric-resize')) return;
const h = document.createElement('div');
h.className = 't-metric-resize';
h.title = 'Изменить размер';
h.innerHTML = '';
el.appendChild(h);
}
// ─── Инициализация всех grid-контейнеров ─────────────────────────────────────
function initMetricsGrids() {
document.querySelectorAll('.t-metrics-grid').forEach(grid => {
grid.querySelectorAll(':scope > .t-metric').forEach(el => {
applyGridSize(el);
addResizeHandle(el);
initResize(el, grid);
});
initDrag(grid);
});
initGridEditButtons();
}
// После динамической вставки нового виджета — подключить его к сетке
function gridRegisterNew(el, grid) {
applyGridSize(el);
addResizeHandle(el);
initResize(el, grid);
if (grid.classList.contains('drag-mode')) el.draggable = true;
}
// ─── Инициализация ────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initMetricWidgets();
initAddMetric();
initMetricsGrids();
initMetricLinks();
});