исправил качество правил и изменение расписания (не черновика)
This commit is contained in:
@@ -119,13 +119,18 @@
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1.15rem 1.3rem;
|
||||
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
|
||||
border: 1px solid color-mix(in srgb, var(--version-copper) 30%, var(--bg-card-border));
|
||||
background: linear-gradient(105deg, color-mix(in srgb, var(--version-mint) 9%, var(--bg-card)), var(--bg-card));
|
||||
border: 1px solid color-mix(in srgb, var(--version-mint) 34%, var(--bg-card-border));
|
||||
border-radius: var(--radius-lg, 20px);
|
||||
transition: background .2s ease, border-color .2s ease;
|
||||
}
|
||||
.version-constructor-bar[data-status="DRAFT"] {
|
||||
background: linear-gradient(105deg, color-mix(in srgb, var(--version-copper) 10%, var(--bg-card)), var(--bg-card));
|
||||
border-color: color-mix(in srgb, var(--version-copper) 35%, var(--bg-card-border));
|
||||
}
|
||||
.version-constructor-bar h2 { margin: 0; color: var(--text-primary); font-family: Georgia, serif; font-size: 1.55rem; }
|
||||
.version-constructor-bar p { margin: .35rem 0 0; color: var(--text-secondary); font-size: .82rem; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .55rem; align-items: end; }
|
||||
.version-constructor-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .55rem; align-items: end; }
|
||||
.version-constructor-controls label { grid-column: 1 / -1; color: var(--text-secondary); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.version-constructor-controls select { min-height: 38px; padding: .45rem .65rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); }
|
||||
|
||||
@@ -279,10 +284,12 @@
|
||||
.version-modal-card > p { margin: .6rem 0 1rem; color: var(--text-secondary); line-height: 1.55; }
|
||||
.version-modal-card form { display: grid; gap: .65rem; }
|
||||
.version-modal-card label { color: var(--text-secondary); font-size: .75rem; font-weight: 700; }
|
||||
.version-modal-card textarea { width: 100%; resize: vertical; padding: .8rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); font: inherit; }
|
||||
.version-modal-card textarea,
|
||||
.version-modal-card input { width: 100%; resize: vertical; padding: .8rem; color: var(--text-primary); background: var(--bg-input); border: 1px solid var(--bg-card-border); border-radius: var(--radius-sm); font: inherit; }
|
||||
.version-modal-close { position: absolute; top: .7rem; right: .8rem; color: var(--text-secondary); background: transparent; border: 0; font-size: 1.6rem; cursor: pointer; }
|
||||
.version-modal-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: .4rem; }
|
||||
.version-modal-summary { padding: .75rem; color: var(--text-secondary); background: var(--bg-input); border-radius: var(--radius-sm); font-size: .8rem; line-height: 1.5; }
|
||||
.version-modal-summary[data-tone="error"] { color: var(--error); border: 1px solid color-mix(in srgb, var(--error) 40%, transparent); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.version-hero, .version-workbench, .version-constructor-bar { grid-template-columns: 1fr; }
|
||||
@@ -296,4 +303,6 @@
|
||||
.version-live-mark { width: 54px; }
|
||||
.version-diff-score { grid-template-columns: 1fr; }
|
||||
.version-history-row { grid-template-columns: 1fr; gap: .25rem; }
|
||||
.version-constructor-controls { grid-template-columns: 1fr; }
|
||||
.version-constructor-controls label { grid-column: auto; }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function initScheduleQuality() {
|
||||
root.querySelector('#quality-severity-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-type-filter')?.addEventListener('change', renderProblems);
|
||||
root.querySelector('#quality-problems')?.addEventListener('click', handleProblemClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRecommendationClick);
|
||||
root.querySelector('#quality-recommendations')?.addEventListener('click', handleRulePanelClick);
|
||||
await loadSemesters();
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ async function runAnalysis() {
|
||||
renderAnalysis();
|
||||
qualityStatus(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик проверен. Исправления вносите в его правила до публикации.'
|
||||
: 'Анализ завершён. Рекомендации ничего не изменят без подтверждения.',
|
||||
? 'Черновик проверен. Проблемные правила можно открыть в конструкторе до публикации.'
|
||||
: 'Анализ завершён. Проблемные правила можно открыть в конструкторе расписания.',
|
||||
'success'
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -131,7 +131,7 @@ function renderAnalysis() {
|
||||
qualityState.root.querySelector('#quality-score-meta').innerHTML = `
|
||||
<span>${escapeHtml(versionStatusLabel(analysis.scheduleVersionStatus))} · v${escapeHtml(String(analysis.scheduleVersionNumber || '—'))}</span>
|
||||
<span>${escapeHtml(String(analysis.lessonCount))} занятий</span>
|
||||
<span>${escapeHtml(String(analysis.totalProblemCount))} проблем</span>
|
||||
<span>правил с проблемами: ${escapeHtml(String(analysis.totalProblemCount))}</span>
|
||||
<span>штраф ${escapeHtml(String(analysis.rawPenalty))}</span>
|
||||
`;
|
||||
qualityState.root.querySelector('#quality-metrics').innerHTML = (analysis.metrics || [])
|
||||
@@ -145,11 +145,11 @@ function renderAnalysis() {
|
||||
</article>
|
||||
`).join('');
|
||||
const totalLabel = analysis.problemsTruncated
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount}`
|
||||
: `${analysis.totalProblemCount} ${problemWord(analysis.totalProblemCount)}`;
|
||||
? `Показаны первые ${analysis.problems.length} из ${analysis.totalProblemCount} правил`
|
||||
: `Правил с проблемами: ${analysis.totalProblemCount}`;
|
||||
qualityState.root.querySelector('#quality-problem-total').textContent = totalLabel;
|
||||
renderProblems();
|
||||
renderRecommendationsEmpty();
|
||||
renderRulePanelEmpty();
|
||||
}
|
||||
|
||||
function renderProblems() {
|
||||
@@ -171,6 +171,9 @@ function renderProblems() {
|
||||
}
|
||||
container.innerHTML = qualityState.visibleProblems.map((problem, index) => {
|
||||
const selected = problem.id === qualityState.selectedProblemId;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
return `
|
||||
<article class="quality-problem ${selected ? 'is-selected' : ''}" data-severity="${escapeHtml(problem.severity)}">
|
||||
<button type="button" class="quality-problem-main" data-problem-index="${index}">
|
||||
@@ -178,199 +181,92 @@ function renderProblems() {
|
||||
<span class="quality-problem-copy">
|
||||
<strong>${escapeHtml(problem.title)}</strong>
|
||||
<span>${escapeHtml(problem.description)}</span>
|
||||
<span class="quality-rule-issues">${issueLabels.map(label => `
|
||||
<span>${escapeHtml(label)}</span>
|
||||
`).join('')}</span>
|
||||
<small>
|
||||
${escapeHtml(formatDate(problem.lessonDate))}
|
||||
· ${escapeHtml(problem.subjectName || 'Занятие')}
|
||||
${problem.timeSlotOrder ? ` · ${escapeHtml(String(problem.timeSlotOrder))} пара` : ''}
|
||||
Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}
|
||||
· ${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку
|
||||
· суммарный штраф
|
||||
</small>
|
||||
</span>
|
||||
<span class="quality-penalty">−${escapeHtml(String(problem.penalty))}</span>
|
||||
</button>
|
||||
<div class="quality-problem-action">
|
||||
${problem.optimizable && qualityState.analysis.scheduleVersionStatus === 'PUBLISHED'
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}">
|
||||
Подобрать улучшения
|
||||
${canEdit
|
||||
? `<button type="button" class="btn btn-sm btn-secondary" data-problem-index="${index}" data-action="edit-rule">
|
||||
Изменить правило
|
||||
</button>`
|
||||
: `<span>${escapeHtml(problemActionLabel(problem))}</span>`}
|
||||
: '<span>Только просмотр</span>'}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function handleProblemClick(event) {
|
||||
function handleProblemClick(event) {
|
||||
const button = event.target.closest('[data-problem-index]');
|
||||
if (!button) return;
|
||||
const problem = qualityState?.visibleProblems[Number(button.dataset.problemIndex)];
|
||||
if (!problem) return;
|
||||
if (button.dataset.action === 'edit-rule') {
|
||||
openRuleInConstructor(problem);
|
||||
return;
|
||||
}
|
||||
qualityState.selectedProblemId = problem.id;
|
||||
renderProblems();
|
||||
if (qualityState.analysis.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
renderRecommendationsMessage(
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Черновик остаётся изолированным'
|
||||
: 'Архивная версия доступна только для проверки',
|
||||
qualityState.analysis.scheduleVersionStatus === 'DRAFT'
|
||||
? 'Откройте его в конструкторе правил и внесите изменения там. Точечные изменения создаются только для опубликованного расписания.'
|
||||
: 'Чтобы снова менять это расписание, восстановите версию или создайте на её основе новый черновик.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!problem.optimizable) {
|
||||
renderRecommendationsMessage(
|
||||
'Ручное решение сохранено',
|
||||
'Для этой пары уже действует точечное изменение. Анализатор не заменяет его автоматически.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await loadRecommendations(problem);
|
||||
renderRuleDetails(problem);
|
||||
}
|
||||
|
||||
async function loadRecommendations(problem) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-loading">
|
||||
<span class="quality-loader"></span>
|
||||
<strong>Проверяем варианты</strong>
|
||||
<p>Каждый кандидат проходит полную проверку конфликтов.</p>
|
||||
</div>
|
||||
`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
semesterId: String(qualityState.analysis.semesterId),
|
||||
versionId: String(qualityState.analysis.scheduleVersionId),
|
||||
scheduleRuleSlotId: String(problem.scheduleRuleSlotId),
|
||||
lessonDate: problem.lessonDate
|
||||
});
|
||||
const result = await api.get(
|
||||
`/api/edu-office/schedule/quality/recommendations?${params.toString()}`
|
||||
);
|
||||
renderRecommendations(problem, result);
|
||||
} catch (error) {
|
||||
renderRecommendationsMessage(
|
||||
'Не удалось подобрать варианты',
|
||||
error.message || 'Повторите попытку после обновления анализа.'
|
||||
);
|
||||
}
|
||||
function renderRulePanelEmpty() {
|
||||
const archived = qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED';
|
||||
renderRulePanelMessage(
|
||||
archived ? 'Архивная версия' : 'Работа с правилом',
|
||||
archived
|
||||
? 'Правила этой версии доступны только для просмотра. Для правок создайте новый черновик.'
|
||||
: 'Выберите правило, чтобы увидеть его суммарное влияние и открыть в конструкторе.'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRecommendations(problem, result) {
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidates = result.candidates || [];
|
||||
if (!candidates.length) {
|
||||
renderRecommendationsMessage(
|
||||
'Свободных улучшений не найдено',
|
||||
'Текущее занятие нельзя локально перенести или заменить аудиторию без нового конфликта.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
function renderRuleDetails(problem) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
const affectedLessonCount = Number(problem.affectedLessonCount || 1);
|
||||
const issueLabels = problem.issueLabels?.length ? problem.issueLabels : [problem.title];
|
||||
const canEdit = qualityState.analysis.scheduleVersionStatus !== 'ARCHIVED' && problem.scheduleRuleId;
|
||||
panel.innerHTML = `
|
||||
<div class="quality-recommendations-head">
|
||||
<span class="quality-kicker">Проверенные варианты</span>
|
||||
<h3>${escapeHtml(problem.title)}</h3>
|
||||
<p>${escapeHtml(formatDate(problem.lessonDate))} · ${escapeHtml(result.subjectName || 'Занятие')}</p>
|
||||
<span class="quality-kicker">Правило #${escapeHtml(String(problem.scheduleRuleId || '—'))}</span>
|
||||
<h3>${escapeHtml(problem.subjectName || problem.title)}</h3>
|
||||
<p>${escapeHtml(String(affectedLessonCount))} ${escapeHtml(lessonWord(affectedLessonCount))} снижают оценку; суммарный штраф −${escapeHtml(String(problem.penalty))}.</p>
|
||||
</div>
|
||||
<div class="quality-candidates">
|
||||
${candidates.map((candidate, index) => recommendationCard(candidate, index)).join('')}
|
||||
<div class="quality-rule-detail-list">
|
||||
${issueLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}
|
||||
</div>
|
||||
<p class="quality-recommendations-note">
|
||||
Применение создаёт обычное точечное изменение и не переписывает правило семестра.
|
||||
</p>
|
||||
`;
|
||||
panel._qualityCandidates = candidates;
|
||||
}
|
||||
|
||||
function recommendationCard(candidate, index) {
|
||||
const impactClass = candidate.penaltyDelta > 0
|
||||
? 'is-positive'
|
||||
: candidate.penaltyDelta < 0 ? 'is-negative' : 'is-neutral';
|
||||
const impact = candidate.scoreDelta > 0
|
||||
? `+${candidate.scoreDelta} к оценке`
|
||||
: candidate.scoreDelta < 0 ? `${candidate.scoreDelta} к оценке` : 'оценка без изменений';
|
||||
return `
|
||||
<article class="quality-candidate ${impactClass}">
|
||||
<div class="quality-candidate-top">
|
||||
<span>${candidate.action === 'MOVE' ? 'Новое время' : 'Новая аудитория'}</span>
|
||||
<strong>${escapeHtml(candidate.label)}</strong>
|
||||
</div>
|
||||
<p>${escapeHtml(candidate.description)}</p>
|
||||
<div class="quality-candidate-impact">
|
||||
<span>${escapeHtml(impact)}</span>
|
||||
<span>результат ${escapeHtml(String(candidate.resultingScore))}/100</span>
|
||||
</div>
|
||||
${changeList('Улучшится', candidate.improvements, 'positive')}
|
||||
${changeList('Компромиссы', candidate.tradeoffs, 'negative')}
|
||||
<button type="button" class="btn btn-md btn-primary quality-apply" data-candidate-index="${index}">
|
||||
Применить после подтверждения
|
||||
${canEdit ? `
|
||||
<button type="button" class="btn btn-md btn-primary quality-edit-rule" data-edit-rule-id="${escapeHtml(String(problem.scheduleRuleId))}">
|
||||
Открыть правило в конструкторе
|
||||
</button>
|
||||
</article>
|
||||
` : '<p class="quality-recommendations-note">Архивное расписание нельзя изменить.</p>'}
|
||||
`;
|
||||
}
|
||||
|
||||
function changeList(label, items, tone) {
|
||||
if (!items?.length) return '';
|
||||
return `
|
||||
<div class="quality-change-list is-${tone}">
|
||||
<strong>${escapeHtml(label)}</strong>
|
||||
${items.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
function handleRulePanelClick(event) {
|
||||
const button = event.target.closest('[data-edit-rule-id]');
|
||||
if (!button) return;
|
||||
const problem = (qualityState?.analysis?.problems || [])
|
||||
.find(item => String(item.scheduleRuleId) === button.dataset.editRuleId);
|
||||
if (problem) openRuleInConstructor(problem);
|
||||
}
|
||||
|
||||
async function handleRecommendationClick(event) {
|
||||
const button = event.target.closest('[data-candidate-index]');
|
||||
if (!button || qualityState?.applying) return;
|
||||
const panel = qualityState.root.querySelector('#quality-recommendations');
|
||||
const candidate = panel._qualityCandidates?.[Number(button.dataset.candidateIndex)];
|
||||
if (!candidate) return;
|
||||
const confirmed = window.confirm(
|
||||
`Применить вариант «${candidate.label}»?\n\nБудет создано точечное изменение расписания.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
qualityState.applying = true;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Применяем…';
|
||||
try {
|
||||
await api.post('/api/edu-office/schedule/overrides', candidate.override);
|
||||
qualityStatus('Рекомендация применена как точечное изменение. Анализ обновлён.', 'success');
|
||||
await runAnalysis();
|
||||
} catch (error) {
|
||||
qualityStatus(error.message || 'Не удалось применить рекомендацию.', 'error');
|
||||
button.disabled = false;
|
||||
button.textContent = 'Применить после подтверждения';
|
||||
} finally {
|
||||
qualityState.applying = false;
|
||||
}
|
||||
function openRuleInConstructor(problem) {
|
||||
if (!problem?.scheduleRuleId || qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') return;
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(qualityState.analysis.scheduleVersionId));
|
||||
localStorage.setItem('magistr.schedule.openRuleId', String(problem.scheduleRuleId));
|
||||
document.querySelector('.nav-item[data-tab="schedule"]')?.click();
|
||||
}
|
||||
|
||||
function renderRecommendationsEmpty() {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus !== 'PUBLISHED') {
|
||||
const isDraft = qualityState.analysis.scheduleVersionStatus === 'DRAFT';
|
||||
renderRecommendationsMessage(
|
||||
isDraft ? 'Режим проверки черновика' : 'Режим проверки архива',
|
||||
isDraft
|
||||
? 'Проблемы рассчитаны по рабочей версии. Откройте черновик в конструкторе, чтобы исправить правила до публикации.'
|
||||
: 'Метрики рассчитаны без изменения архивной версии. Для правок создайте новый черновик или восстановите эту публикацию.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderRecommendationsMessage(
|
||||
'Локальные улучшения',
|
||||
'Выберите проблему, чтобы проверить свободные слоты и аудитории.'
|
||||
);
|
||||
}
|
||||
|
||||
function problemActionLabel(problem) {
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'DRAFT') {
|
||||
return 'Исправляется в конструкторе';
|
||||
}
|
||||
if (qualityState?.analysis?.scheduleVersionStatus === 'ARCHIVED') {
|
||||
return 'Только просмотр';
|
||||
}
|
||||
return problem.optimizable ? 'Нет доступного действия' : 'Закреплено вручную';
|
||||
}
|
||||
|
||||
function renderRecommendationsMessage(title, message) {
|
||||
function renderRulePanelMessage(title, message) {
|
||||
const panel = qualityState?.root.querySelector('#quality-recommendations');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = `
|
||||
@@ -380,7 +276,6 @@ function renderRecommendationsMessage(title, message) {
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
`;
|
||||
panel._qualityCandidates = [];
|
||||
}
|
||||
|
||||
function qualityStatus(message, tone = '') {
|
||||
@@ -393,7 +288,8 @@ function qualityStatus(message, tone = '') {
|
||||
export function filterQualityProblems(problems, severity = '', category = '') {
|
||||
return (problems || []).filter(problem => {
|
||||
const severityMatches = !severity || problem.severity === severity;
|
||||
const categoryMatches = !category || problemCategory(problem.type) === category;
|
||||
const problemTypes = problem.problemTypes?.length ? problem.problemTypes : [problem.type];
|
||||
const categoryMatches = !category || problemTypes.some(type => problemCategory(type) === category);
|
||||
return severityMatches && categoryMatches;
|
||||
});
|
||||
}
|
||||
@@ -448,13 +344,13 @@ function localIsoDate(date) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function problemWord(count) {
|
||||
function lessonWord(count) {
|
||||
const mod100 = count % 100;
|
||||
const mod10 = count % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'проблем';
|
||||
if (mod10 === 1) return 'проблема';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'проблемы';
|
||||
return 'проблем';
|
||||
if (mod100 >= 11 && mod100 <= 14) return 'занятий';
|
||||
if (mod10 === 1) return 'занятие';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'занятия';
|
||||
return 'занятий';
|
||||
}
|
||||
|
||||
export function versionStatusLabel(status) {
|
||||
|
||||
@@ -113,6 +113,7 @@ function renderCurrent(version) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-card-actions">
|
||||
<button type="button" class="btn btn-sm btn-primary" data-action="edit" data-id="${version.id}">Редактировать</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="quality" data-id="${version.id}">Проверить качество</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -196,7 +197,7 @@ async function handleVersionAction(event) {
|
||||
if (!version) return;
|
||||
const action = button.dataset.action;
|
||||
if (action === 'edit') {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(version.id));
|
||||
localStorage.setItem('magistr.schedule.openVersionId', String(version.id));
|
||||
navigateTo('schedule');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ export function scheduleSlotActionLabel(slotCount) {
|
||||
return slotCount === 1 ? 'Очистить' : 'Удалить';
|
||||
}
|
||||
|
||||
export function chooseScheduleVersion(versions, semesterId = '', requestedVersionId = '') {
|
||||
const available = (versions || []).filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
return available.find(version => String(version.id) === String(requestedVersionId))
|
||||
|| available.find(version => version.status === 'PUBLISHED'
|
||||
&& String(version.semesterId) === String(semesterId))
|
||||
|| available.find(version => version.status === 'PUBLISHED')
|
||||
|| available.find(version => String(version.semesterId) === String(semesterId))
|
||||
|| available[0]
|
||||
|| null;
|
||||
}
|
||||
|
||||
const LESSON_TYPE_LIMITS = [
|
||||
{
|
||||
key: 'lecture',
|
||||
@@ -68,8 +79,20 @@ export async function initSchedule() {
|
||||
const ruleSubjectSelect = document.getElementById('schedule-rule-subject');
|
||||
const ruleSemesterSelect = document.getElementById('schedule-rule-semester');
|
||||
const scheduleVersionSelect = document.getElementById('schedule-version-select');
|
||||
const scheduleVersionContext = document.getElementById('schedule-version-context');
|
||||
const scheduleVersionEyebrow = document.getElementById('schedule-version-eyebrow');
|
||||
const scheduleVersionTitle = document.getElementById('schedule-version-title');
|
||||
const scheduleVersionNote = document.getElementById('schedule-version-note');
|
||||
const scheduleVersionCreateDraft = document.getElementById('schedule-version-create-draft');
|
||||
const scheduleVersionManage = document.getElementById('schedule-version-manage');
|
||||
const draftModal = document.getElementById('schedule-draft-modal');
|
||||
const draftForm = document.getElementById('schedule-draft-form');
|
||||
const draftNameInput = document.getElementById('schedule-draft-name');
|
||||
const draftCopy = document.getElementById('schedule-draft-copy');
|
||||
const draftSummary = document.getElementById('schedule-draft-summary');
|
||||
const draftCloseButton = document.getElementById('schedule-draft-close');
|
||||
const draftCancelButton = document.getElementById('schedule-draft-cancel');
|
||||
const draftSubmitButton = document.getElementById('schedule-draft-submit');
|
||||
const ruleTypeInputs = Object.fromEntries(LESSON_TYPE_LIMITS.map(config => [config.key, {
|
||||
hours: document.getElementById(config.hoursInputId),
|
||||
startWeek: document.getElementById(config.startWeekInputId)
|
||||
@@ -138,6 +161,7 @@ export async function initSchedule() {
|
||||
let pendingConflictResolution = null;
|
||||
let visualContextTarget = null;
|
||||
let pendingSlotMove = null;
|
||||
let creatingDraft = false;
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -159,13 +183,21 @@ export async function initSchedule() {
|
||||
if (scheduleVersionSelect.value) {
|
||||
localStorage.setItem('magistr.schedule.versionId', scheduleVersionSelect.value);
|
||||
}
|
||||
updateVersionContext();
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
});
|
||||
scheduleVersionCreateDraft?.addEventListener('click', openDraftModal);
|
||||
scheduleVersionManage?.addEventListener('click', () => {
|
||||
document.querySelector('.nav-item[data-tab="schedule-versions"]')?.click();
|
||||
});
|
||||
draftCloseButton?.addEventListener('click', closeDraftModal);
|
||||
draftCancelButton?.addEventListener('click', closeDraftModal);
|
||||
draftModal?.addEventListener('click', event => {
|
||||
if (event.target === draftModal) closeDraftModal();
|
||||
});
|
||||
draftForm?.addEventListener('submit', createDraftFromSelectedVersion);
|
||||
ruleResetButton?.addEventListener('click', resetRuleForm);
|
||||
slotAddButton?.addEventListener('click', () => renderRuleSlots([...readRuleSlots(false), {}]));
|
||||
visualToggleButton?.addEventListener('click', toggleVisualDrawer);
|
||||
@@ -211,6 +243,10 @@ export async function initSchedule() {
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (draftModal && !draftModal.hidden) {
|
||||
closeDraftModal();
|
||||
return;
|
||||
}
|
||||
if (moveSlotModal?.classList.contains('open')) {
|
||||
closeMoveSlotModal();
|
||||
return;
|
||||
@@ -302,31 +338,123 @@ export async function initSchedule() {
|
||||
updatePreferenceHints();
|
||||
}
|
||||
|
||||
async function loadScheduleVersions() {
|
||||
async function loadScheduleVersions(preferredVersionId = null) {
|
||||
const responses = await Promise.all(semesters.map(semester =>
|
||||
api.get(`/api/edu-office/schedule/versions?semesterId=${encodeURIComponent(semester.id)}`)
|
||||
));
|
||||
scheduleVersions = responses.flat().filter(version => version.status === 'DRAFT');
|
||||
const storedId = localStorage.getItem('magistr.schedule.versionId');
|
||||
const stored = scheduleVersions.find(version => String(version.id) === storedId);
|
||||
const selected = stored || scheduleVersions[0] || null;
|
||||
scheduleVersions = responses.flat().filter(version => ['PUBLISHED', 'DRAFT'].includes(version.status));
|
||||
const requestedVersionId = preferredVersionId || localStorage.getItem('magistr.schedule.openVersionId') || '';
|
||||
localStorage.removeItem('magistr.schedule.openVersionId');
|
||||
const selected = chooseScheduleVersion(
|
||||
scheduleVersions,
|
||||
ruleSemesterSelect.value || defaultScheduleSemesterId(),
|
||||
requestedVersionId
|
||||
);
|
||||
const published = scheduleVersions.filter(version => version.status === 'PUBLISHED');
|
||||
const drafts = scheduleVersions.filter(version => version.status === 'DRAFT');
|
||||
scheduleVersionSelect.innerHTML = scheduleVersions.length
|
||||
? scheduleVersions.map(version => `
|
||||
<option value="${version.id}">
|
||||
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
|
||||
</option>
|
||||
`).join('')
|
||||
: '<option value="">Черновики не созданы</option>';
|
||||
? [
|
||||
versionOptionGroup('Опубликованные расписания', published),
|
||||
versionOptionGroup('Черновики', drafts)
|
||||
].filter(Boolean).join('')
|
||||
: '<option value="">Расписания не созданы</option>';
|
||||
scheduleVersionSelect.value = selected ? String(selected.id) : '';
|
||||
if (selected) {
|
||||
localStorage.setItem('magistr.schedule.versionId', String(selected.id));
|
||||
scheduleVersionNote.textContent = `Версия ${selected.versionNumber} · ${selected.name}. Изменения не видны пользователям до публикации.`;
|
||||
} else {
|
||||
scheduleVersionNote.textContent = 'Черновика нет. Создайте его в разделе «Версии расписания».';
|
||||
}
|
||||
updateVersionContext();
|
||||
setRuleEditorEnabled(Boolean(selected));
|
||||
}
|
||||
|
||||
function versionOptionGroup(label, versions) {
|
||||
if (!versions.length) return '';
|
||||
return `<optgroup label="${escapeHtml(label)}">${versions.map(version => `
|
||||
<option value="${version.id}">
|
||||
${escapeHtml(version.semesterLabel)} · v${escapeHtml(String(version.versionNumber))} · ${escapeHtml(version.name)}
|
||||
</option>
|
||||
`).join('')}</optgroup>`;
|
||||
}
|
||||
|
||||
function defaultScheduleSemesterId() {
|
||||
const savedSemesterId = localStorage.getItem('magistr.schedule.semesterId');
|
||||
const saved = semesters.find(semester => String(semester.id) === String(savedSemesterId));
|
||||
if (saved) return saved.id;
|
||||
const today = localIsoDate(new Date());
|
||||
const current = semesters.find(semester => semester.startDate <= today && semester.endDate >= today);
|
||||
return current?.id || semesters[0]?.id || '';
|
||||
}
|
||||
|
||||
function updateVersionContext() {
|
||||
const version = selectedScheduleVersion();
|
||||
const isDraft = version?.status === 'DRAFT';
|
||||
if (scheduleVersionContext) scheduleVersionContext.dataset.status = isDraft ? 'DRAFT' : 'PUBLISHED';
|
||||
if (scheduleVersionEyebrow) scheduleVersionEyebrow.textContent = isDraft ? 'Безопасная рабочая копия' : 'Основной режим';
|
||||
if (scheduleVersionTitle) scheduleVersionTitle.textContent = isDraft ? 'Редактирование черновика' : 'Опубликованное расписание';
|
||||
if (!version) {
|
||||
scheduleVersionNote.textContent = 'Расписание для редактирования не найдено.';
|
||||
return;
|
||||
}
|
||||
scheduleVersionNote.textContent = isDraft
|
||||
? `Версия ${version.versionNumber} · ${version.name}. Изменения не видны пользователям до публикации.`
|
||||
: `Версия ${version.versionNumber} · ${version.name}. Сохранённые изменения сразу видны пользователям.`;
|
||||
}
|
||||
|
||||
function openDraftModal() {
|
||||
const base = selectedScheduleVersion();
|
||||
if (!base || creatingDraft) return;
|
||||
draftForm?.reset();
|
||||
if (draftCopy) {
|
||||
draftCopy.textContent = `За основу будет взято «${base.name}» — все его правила и слоты будут скопированы.`;
|
||||
}
|
||||
if (draftSummary) {
|
||||
draftSummary.textContent = `${base.semesterLabel} · версия ${base.versionNumber}`;
|
||||
draftSummary.dataset.tone = '';
|
||||
}
|
||||
draftModal.hidden = false;
|
||||
draftNameInput?.focus();
|
||||
}
|
||||
|
||||
function closeDraftModal() {
|
||||
if (!draftModal || creatingDraft) return;
|
||||
draftModal.hidden = true;
|
||||
}
|
||||
|
||||
async function createDraftFromSelectedVersion(event) {
|
||||
event.preventDefault();
|
||||
const base = selectedScheduleVersion();
|
||||
const name = draftNameInput?.value.trim();
|
||||
if (!base || !name || creatingDraft) return;
|
||||
creatingDraft = true;
|
||||
if (draftSubmitButton) {
|
||||
draftSubmitButton.disabled = true;
|
||||
draftSubmitButton.textContent = 'Создаём…';
|
||||
}
|
||||
try {
|
||||
const created = await api.post('/api/edu-office/schedule/versions', {
|
||||
semesterId: Number(base.semesterId),
|
||||
name,
|
||||
basedOnVersionId: Number(base.id)
|
||||
});
|
||||
draftModal.hidden = true;
|
||||
await loadScheduleVersions(created.id);
|
||||
resetRuleForm();
|
||||
syncRuleSemesterWithVersion();
|
||||
await loadRules();
|
||||
showAlert('schedule-rule-alert', `Черновик «${created.name}» создан и открыт.`, 'success');
|
||||
} catch (error) {
|
||||
if (draftSummary) {
|
||||
draftSummary.textContent = error.message || 'Не удалось создать черновик.';
|
||||
draftSummary.dataset.tone = 'error';
|
||||
}
|
||||
} finally {
|
||||
creatingDraft = false;
|
||||
if (draftSubmitButton) {
|
||||
draftSubmitButton.disabled = false;
|
||||
draftSubmitButton.textContent = 'Создать и открыть';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectedScheduleVersion() {
|
||||
return scheduleVersions.find(version => String(version.id) === String(scheduleVersionSelect?.value)) || null;
|
||||
}
|
||||
@@ -352,8 +480,8 @@ export async function initSchedule() {
|
||||
const version = selectedScheduleVersion();
|
||||
if (!version) {
|
||||
rules = [];
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Создайте черновик в разделе «Версии расписания»</td></tr>';
|
||||
setVisualState('Нет выбранного черновика');
|
||||
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет расписания для редактирования</td></tr>';
|
||||
setVisualState('Нет выбранного расписания');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1390,7 +1518,7 @@ export async function initSchedule() {
|
||||
const groupIds = selectedRuleGroupIds();
|
||||
const slots = readRuleSlots(true);
|
||||
if (!ruleSubjectSelect.value) throw new Error('Выберите дисциплину');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите черновик расписания');
|
||||
if (!scheduleVersionSelect.value) throw new Error('Выберите версию расписания');
|
||||
if (!ruleSemesterSelect.value) throw new Error('Выберите семестр');
|
||||
const typeLimits = readTypeLimits();
|
||||
validateTypeLimits(typeLimits, slots);
|
||||
@@ -1456,7 +1584,7 @@ export async function initSchedule() {
|
||||
const actionLabel = scheduleSlotActionLabel(normalizedSlots.length);
|
||||
const actionClass = isSingleSlot ? 'btn-ghost' : 'btn-danger';
|
||||
ruleSlotsContainer.innerHTML = normalizedSlots.map((slot, index) => `
|
||||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}">
|
||||
<div class="schedule-slot-row ${isLaboratorySlot(slot) ? 'has-subgroup' : ''}" data-index="${index}" data-slot-id="${escapeHtml(String(slot.id || ''))}">
|
||||
<div class="form-group"><label>День</label><select class="slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
|
||||
<div class="form-group"><label>Чётность</label><select class="slot-parity" required>${options(Object.entries(PARITY_LABELS).map(([value, label]) => ({ value, label })), slot.parity || 'BOTH')}</select></div>
|
||||
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
|
||||
@@ -1521,8 +1649,9 @@ export async function initSchedule() {
|
||||
const rows = Array.from(ruleSlotsContainer.querySelectorAll('.schedule-slot-row'));
|
||||
return rows.map((row, index) => {
|
||||
const subgroupIds = readSlotSubgroupIds(row);
|
||||
const slotId = Number(row.dataset.slotId);
|
||||
const slot = {
|
||||
id: null,
|
||||
id: slotId || null,
|
||||
dayOfWeek: Number(row.querySelector('.slot-day').value),
|
||||
parity: row.querySelector('.slot-parity').value,
|
||||
timeSlotId: Number(row.querySelector('.slot-time').value),
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
<section class="version-constructor-bar" id="schedule-version-context">
|
||||
<section class="version-constructor-bar" id="schedule-version-context" data-status="PUBLISHED">
|
||||
<div>
|
||||
<span class="version-eyebrow">Изолированный контекст</span>
|
||||
<h2>Рабочий черновик</h2>
|
||||
<p id="schedule-version-note">Выберите черновик: только его правила можно изменять в конструкторе.</p>
|
||||
<span class="version-eyebrow" id="schedule-version-eyebrow">Основной режим</span>
|
||||
<h2 id="schedule-version-title">Опубликованное расписание</h2>
|
||||
<p id="schedule-version-note">Изменения применяются сразу. Для безопасной подготовки можно создать черновик.</p>
|
||||
</div>
|
||||
<div class="version-constructor-controls">
|
||||
<label for="schedule-version-select">Версия расписания</label>
|
||||
<select id="schedule-version-select" data-native-select="true">
|
||||
<option value="">Загрузка черновиков…</option>
|
||||
<option value="">Загрузка расписаний…</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Управление версиями</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="schedule-version-create-draft">Создать черновик</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="schedule-version-manage">Все версии</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="version-modal schedule-draft-modal" id="schedule-draft-modal" hidden>
|
||||
<div class="version-modal-card" role="dialog" aria-modal="true" aria-labelledby="schedule-draft-title">
|
||||
<button type="button" class="version-modal-close" id="schedule-draft-close" aria-label="Закрыть">×</button>
|
||||
<span class="version-eyebrow">Новая рабочая копия</span>
|
||||
<h3 id="schedule-draft-title">Создать черновик</h3>
|
||||
<p id="schedule-draft-copy">Все правила выбранного расписания будут скопированы.</p>
|
||||
<form id="schedule-draft-form">
|
||||
<label for="schedule-draft-name">Название черновика</label>
|
||||
<input id="schedule-draft-name" type="text" maxlength="160" required
|
||||
placeholder="Например, расписание после распределения аудиторий">
|
||||
<div class="version-modal-summary" id="schedule-draft-summary"></div>
|
||||
<div class="version-modal-actions">
|
||||
<button type="button" class="btn btn-md btn-ghost" id="schedule-draft-cancel">Отмена</button>
|
||||
<button type="submit" class="btn btn-md btn-primary" id="schedule-draft-submit">Создать и открыть</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card create-card" id="schedule-rule-editor">
|
||||
<div class="card-header-row">
|
||||
<h2 id="schedule-rule-form-title">Новое правило расписания</h2>
|
||||
|
||||
Reference in New Issue
Block a user