Добавил возможность решения конкфликтов при создании правил

This commit is contained in:
dipatrik10
2026-06-03 13:55:31 +03:00
parent 776728cf83
commit 53298d9d07
8 changed files with 972 additions and 26 deletions

View File

@@ -642,6 +642,185 @@
grid-template-columns: repeat(8, minmax(125px, 1fr)) auto;
}
.schedule-conflict-modal {
z-index: 1200;
}
.schedule-conflict-card {
width: min(1440px, calc(100vw - 2rem));
padding: 1.5rem;
}
.schedule-conflict-head {
display: grid;
gap: 0.45rem;
margin-right: 2rem;
margin-bottom: 1rem;
}
.schedule-conflict-head h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.05rem;
letter-spacing: 0;
text-transform: none;
}
.schedule-conflict-head p {
margin: 0;
color: var(--text-secondary);
line-height: 1.45;
}
.schedule-conflict-kicker {
width: max-content;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(245, 158, 11, 0.3);
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
font-size: 0.72rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.schedule-conflict-rule-box {
display: grid;
gap: 0.35rem;
min-width: 0;
padding: 0.85rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.schedule-conflict-rule-box-active {
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
}
.schedule-conflict-rule-box span,
.schedule-conflict-slot-fixed span {
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.schedule-conflict-rule-box strong,
.schedule-conflict-slot-fixed strong {
min-width: 0;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.schedule-conflict-rule-box small,
.schedule-conflict-slot-fixed small {
color: var(--text-secondary);
line-height: 1.4;
}
.schedule-conflict-form {
display: grid;
gap: 0.75rem;
}
.schedule-conflict-slots {
display: grid;
gap: 0.75rem;
max-height: none;
overflow: visible;
padding-right: 0.25rem;
}
.schedule-conflict-slot-row {
display: grid;
grid-template-columns: minmax(210px, 1.15fr) minmax(130px, 0.7fr) minmax(140px, 0.75fr) minmax(170px, 0.85fr) minmax(240px, 1.15fr) minmax(190px, 1fr);
gap: 0.65rem;
align-items: end;
padding: 0.75rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-input) 88%, transparent);
}
.schedule-conflict-slot-fixed {
display: grid;
gap: 0.25rem;
align-self: stretch;
align-content: center;
min-width: 0;
}
.schedule-conflict-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.25rem;
}
.schedule-visual-context-menu {
position: fixed;
z-index: 1300;
display: grid;
min-width: 220px;
padding: 0.35rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
}
.schedule-visual-context-menu[hidden] {
display: none;
}
[data-theme="light"] .schedule-visual-context-menu {
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
}
.schedule-visual-context-menu button {
width: 100%;
padding: 0.65rem 0.75rem;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.88rem;
text-align: left;
cursor: pointer;
}
.schedule-visual-context-menu button:hover,
.schedule-visual-context-menu button:focus-visible {
outline: none;
background: var(--bg-hover);
}
.schedule-visual-context-menu button.danger {
color: var(--error);
}
.schedule-move-slot-card {
width: min(620px, calc(100vw - 2rem));
}
.schedule-move-slot-form {
display: grid;
gap: 0.9rem;
}
.subject-import-rows-list {
display: flex;
flex-direction: column;
@@ -861,6 +1040,23 @@
.subject-import-row .btn {
width: 100%;
}
.schedule-conflict-card {
padding: 1.25rem;
}
.schedule-conflict-rules,
.schedule-conflict-slot-row {
grid-template-columns: 1fr;
}
.schedule-conflict-actions {
flex-direction: column-reverse;
}
.schedule-conflict-actions .btn {
width: 100%;
}
}
.academic-year-semester-meta {

View File

@@ -63,7 +63,10 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
const error = new Error(data?.message || `Ошибка HTTP: ${response.status}`);
error.status = response.status;
error.data = data;
throw error;
}
return data;

View File

@@ -87,6 +87,27 @@ export async function initSchedule() {
const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
const conflictModal = document.getElementById('schedule-conflict-modal');
const conflictForm = document.getElementById('schedule-conflict-form');
const conflictCloseButton = document.getElementById('schedule-conflict-close');
const conflictCancelButton = document.getElementById('schedule-conflict-cancel');
const conflictSubmitButton = document.getElementById('schedule-conflict-submit');
const conflictSlotsContainer = document.getElementById('schedule-conflict-slots');
const conflictSummary = document.getElementById('schedule-conflict-summary');
const conflictNewRuleTitle = document.getElementById('schedule-conflict-new-rule');
const conflictNewRuleMeta = document.getElementById('schedule-conflict-new-rule-meta');
const conflictExistingRuleTitle = document.getElementById('schedule-conflict-existing-rule');
const conflictExistingRuleMeta = document.getElementById('schedule-conflict-existing-rule-meta');
const visualContextMenu = document.getElementById('schedule-visual-context-menu');
const moveSlotModal = document.getElementById('schedule-move-slot-modal');
const moveSlotForm = document.getElementById('schedule-move-slot-form');
const moveSlotCloseButton = document.getElementById('schedule-move-slot-close');
const moveSlotCancelButton = document.getElementById('schedule-move-slot-cancel');
const moveSlotSubmitButton = document.getElementById('schedule-move-slot-submit');
const moveSlotTitle = document.getElementById('schedule-move-slot-title');
const moveSlotSummary = document.getElementById('schedule-move-slot-summary');
const moveSlotDaySelect = document.getElementById('schedule-move-slot-day');
const moveSlotTimeSelect = document.getElementById('schedule-move-slot-time');
let rules = [];
let subjects = [];
@@ -104,6 +125,9 @@ export async function initSchedule() {
let visualGroupFilterTouched = false;
let visualPeriodTouched = false;
let syncingVisualPeriod = false;
let pendingConflictResolution = null;
let visualContextTarget = null;
let pendingSlotMove = null;
bindEvents();
@@ -127,6 +151,19 @@ export async function initSchedule() {
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick);
conflictCloseButton?.addEventListener('click', closeConflictModal);
conflictCancelButton?.addEventListener('click', closeConflictModal);
conflictModal?.addEventListener('click', (event) => {
if (event.target === conflictModal) closeConflictModal();
});
conflictForm?.addEventListener('submit', resolveScheduleRuleConflict);
visualContextMenu?.addEventListener('click', handleVisualContextMenuClick);
moveSlotCloseButton?.addEventListener('click', closeMoveSlotModal);
moveSlotCancelButton?.addEventListener('click', closeMoveSlotModal);
moveSlotModal?.addEventListener('click', (event) => {
if (event.target === moveSlotModal) closeMoveSlotModal();
});
moveSlotForm?.addEventListener('submit', saveVisualSlotMove);
visualUseFormButton?.addEventListener('click', () => {
const formSemesterId = ruleSemesterSelect.value;
if (formSemesterId) {
@@ -148,7 +185,25 @@ export async function initSchedule() {
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer();
if (event.key !== 'Escape') return;
if (moveSlotModal?.classList.contains('open')) {
closeMoveSlotModal();
return;
}
if (conflictModal?.classList.contains('open')) {
closeConflictModal();
return;
}
if (visualContextMenu && !visualContextMenu.hidden) {
closeVisualContextMenu();
return;
}
closeVisualDrawer();
});
document.addEventListener('click', (event) => {
if (visualContextMenu?.hidden) return;
if (event.target.closest('#schedule-visual-context-menu') || event.target.closest('.schedule-visual-edit-rule')) return;
closeVisualContextMenu();
});
ruleSlotsContainer.addEventListener('click', (event) => {
@@ -401,6 +456,10 @@ export async function initSchedule() {
const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`,
ruleId: rule.id,
slotId: slot.id || '',
slotIndex: index,
dayOfWeek: slot.dayOfWeek,
timeSlotId: slot.timeSlotId,
parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '',
@@ -498,8 +557,10 @@ export async function initSchedule() {
<button type="button"
class="schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
title="Изменить правило"
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">&#8942;</button>
data-slot-id="${escapeHtml(String(lesson.slotId || ''))}"
data-slot-index="${escapeHtml(String(lesson.slotIndex ?? ''))}"
title="Действия со слотом"
aria-label="${escapeHtml(`Действия со слотом: ${lesson.subjectName}`)}">&#8942;</button>
</article>
`;
}
@@ -580,14 +641,197 @@ export async function initSchedule() {
function handleVisualStageClick(event) {
const editButton = event.target.closest('.schedule-visual-edit-rule');
if (!editButton) return;
const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId));
event.stopPropagation();
openVisualContextMenu(editButton);
}
function openVisualContextMenu(button) {
const rule = rules.find(item => String(item.id) === String(button.dataset.ruleId));
if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
fillRuleForm(rule);
closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
visualContextTarget = {
ruleId: button.dataset.ruleId,
slotId: button.dataset.slotId || '',
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
function closeVisualContextMenu() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
async function handleVisualContextMenuClick(event) {
const actionButton = event.target.closest('button[data-action]');
if (!actionButton || !visualContextTarget) return;
const action = actionButton.dataset.action;
const target = { ...visualContextTarget };
const rule = rules.find(item => String(item.id) === String(target.ruleId));
if (!rule) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
if (action === 'edit-rule') {
closeVisualContextMenu();
fillRuleForm(rule);
closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
return;
}
if (action === 'move-slot') {
openMoveSlotModal(target);
return;
}
if (action === 'delete-rule') {
await deleteRuleFromVisual(rule);
}
}
async function deleteRuleFromVisual(rule) {
closeVisualContextMenu();
if (!confirm(`Удалить правило "${rule.subjectName || 'расписания'}"?`)) return;
try {
await api.delete('/api/admin/schedule-rules/' + rule.id);
showAlert('schedule-rule-alert', 'Правило расписания удалено', 'success');
await loadRules();
} catch (error) {
showAlert('schedule-rule-alert', error.message || 'Ошибка удаления правила', 'error');
}
}
function openMoveSlotModal(target) {
const rule = rules.find(item => String(item.id) === String(target.ruleId));
const slotIndex = findRuleSlotIndex(rule, target);
const slot = slotIndex >= 0 ? rule.slots[slotIndex] : null;
if (!rule || !slot) {
closeVisualContextMenu();
showAlert('schedule-rule-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
pendingSlotMove = {
ruleId: String(rule.id),
slotIndex
};
if (moveSlotTitle) {
moveSlotTitle.textContent = rule.subjectName || 'Изменить день и пару';
}
if (moveSlotSummary) {
moveSlotSummary.textContent = `Текущий слот: ${conflictSlotCurrentText(slot) || 'не указан'}`;
}
if (moveSlotDaySelect) {
moveSlotDaySelect.innerHTML = options(DAY_OPTIONS, slot.dayOfWeek);
}
if (moveSlotTimeSelect) {
moveSlotTimeSelect.innerHTML = options(timeSlots.map(toTimeSlotOption), slot.timeSlotId);
}
hideAlert('schedule-move-slot-alert');
closeVisualContextMenu();
moveSlotModal?.classList.add('open');
moveSlotModal?.setAttribute('aria-hidden', 'false');
moveSlotDaySelect?.focus();
}
function closeMoveSlotModal() {
pendingSlotMove = null;
moveSlotModal?.classList.remove('open');
moveSlotModal?.setAttribute('aria-hidden', 'true');
hideAlert('schedule-move-slot-alert');
}
async function saveVisualSlotMove(event) {
event.preventDefault();
if (!pendingSlotMove) return;
hideAlert('schedule-move-slot-alert');
const rule = rules.find(item => String(item.id) === pendingSlotMove.ruleId);
const slot = rule?.slots?.[pendingSlotMove.slotIndex];
if (!rule || !slot) {
showAlert('schedule-move-slot-alert', 'Слот правила не найден. Обновите список правил.', 'error');
return;
}
const dayOfWeek = Number(moveSlotDaySelect?.value);
const timeSlotId = Number(moveSlotTimeSelect?.value);
if (!dayOfWeek || !timeSlotId) {
showAlert('schedule-move-slot-alert', 'Выберите день и пару', 'error');
return;
}
const previousButtonText = moveSlotSubmitButton?.textContent || '';
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = true;
moveSlotSubmitButton.textContent = 'Сохраняем...';
}
try {
const slots = (rule.slots || []).map(ruleSlotToPayload);
slots[pendingSlotMove.slotIndex] = {
...slots[pendingSlotMove.slotIndex],
dayOfWeek,
timeSlotId
};
const payload = buildRulePayloadFromRule(rule, slots);
const saved = await api.put('/api/admin/schedule-rules/' + rule.id, payload);
closeMoveSlotModal();
showAlert('schedule-rule-alert', `Слот правила "${saved.subjectName || 'расписания'}" перенесён`, 'success');
await loadRules();
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-move-slot-alert',
`Новое место занято правилом "${ruleTitle(error.data.conflictRule)}". Выберите другой день или пару.`,
'error'
);
return;
}
showAlert('schedule-move-slot-alert', error.message || 'Ошибка переноса слота', 'error');
} finally {
if (moveSlotSubmitButton) {
moveSlotSubmitButton.disabled = false;
moveSlotSubmitButton.textContent = previousButtonText || 'Сохранить перенос';
}
}
}
function findRuleSlotIndex(rule, target) {
if (!rule?.slots?.length) return -1;
if (target.slotId) {
const byId = rule.slots.findIndex(slot => String(slot.id) === String(target.slotId));
if (byId >= 0) return byId;
}
const index = Number(target.slotIndex);
return Number.isInteger(index) && index >= 0 && index < rule.slots.length ? index : -1;
}
function ruleSlotToPayload(slot) {
return {
id: slot.id || null,
dayOfWeek: Number(slot.dayOfWeek),
parity: slot.parity || 'BOTH',
timeSlotId: Number(slot.timeSlotId),
subgroupId: slot.subgroupId || null,
subgroupIds: slotSubgroupIds(slot),
teacherId: Number(slot.teacherId),
classroomId: Number(slot.classroomId),
lessonTypeId: Number(slot.lessonTypeId),
lessonFormat: slot.lessonFormat || 'Очно'
};
}
function syncVisualGroupFilter(allGroups) {
@@ -770,20 +1014,267 @@ export async function initSchedule() {
async function saveRule(event) {
event.preventDefault();
hideAlert('schedule-rule-alert');
let payload = null;
let id = '';
try {
const payload = buildRulePayload();
const id = ruleIdInput.value;
const saved = id
? await api.put('/api/admin/schedule-rules/' + id, payload)
: await api.post('/api/admin/schedule-rules', payload);
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
resetRuleForm();
await loadRules();
payload = buildRulePayload();
id = ruleIdInput.value;
const saved = await saveRulePayload(id, payload);
await finishSuccessfulRuleSave(saved);
} catch (error) {
if (payload && isScheduleRuleConflictError(error)) {
openConflictModal(error, payload, id);
return;
}
showAlert('schedule-rule-alert', error.message || 'Ошибка сохранения правила', 'error');
}
}
async function saveRulePayload(id, payload) {
return id
? api.put('/api/admin/schedule-rules/' + id, payload)
: api.post('/api/admin/schedule-rules', payload);
}
async function finishSuccessfulRuleSave(saved) {
resetRuleForm();
showAlert('schedule-rule-alert', `Правило "${saved.subjectName || 'расписания'}" сохранено`, 'success');
await loadRules();
}
function openConflictModal(error, pendingPayload, pendingRuleId) {
const conflictRule = error.data.conflictRule;
pendingConflictResolution = {
conflictRule: clonePlain(conflictRule),
pendingPayload: clonePlain(pendingPayload),
pendingRuleId: pendingRuleId || ''
};
if (conflictSummary) {
conflictSummary.textContent = error.message || 'Ранее созданное правило занимает выбранный слот.';
}
if (conflictNewRuleTitle) {
conflictNewRuleTitle.textContent = ruleDraftTitle(pendingPayload);
}
if (conflictNewRuleMeta) {
conflictNewRuleMeta.textContent = ruleDraftMeta(pendingPayload);
}
if (conflictExistingRuleTitle) {
conflictExistingRuleTitle.textContent = ruleTitle(conflictRule);
}
if (conflictExistingRuleMeta) {
conflictExistingRuleMeta.textContent = ruleMeta(conflictRule);
}
renderConflictSlots(conflictRule);
hideAlert('schedule-conflict-alert');
conflictModal?.classList.add('open');
conflictModal?.setAttribute('aria-hidden', 'false');
}
function closeConflictModal() {
pendingConflictResolution = null;
conflictModal?.classList.remove('open');
conflictModal?.setAttribute('aria-hidden', 'true');
if (conflictSlotsContainer) conflictSlotsContainer.innerHTML = '';
hideAlert('schedule-conflict-alert');
}
async function resolveScheduleRuleConflict(event) {
event.preventDefault();
if (!pendingConflictResolution) return;
hideAlert('schedule-conflict-alert');
const previousButtonText = conflictSubmitButton?.textContent || '';
if (conflictSubmitButton) {
conflictSubmitButton.disabled = true;
conflictSubmitButton.textContent = 'Сохраняем...';
}
try {
const currentResolution = pendingConflictResolution;
const conflictPayload = buildRulePayloadFromRule(
currentResolution.conflictRule,
readConflictSlots(true)
);
await api.put('/api/admin/schedule-rules/' + currentResolution.conflictRule.id, conflictPayload);
try {
const saved = await saveRulePayload(
currentResolution.pendingRuleId,
currentResolution.pendingPayload
);
closeConflictModal();
await finishSuccessfulRuleSave(saved);
} catch (retryError) {
if (isScheduleRuleConflictError(retryError)) {
await loadRules();
openConflictModal(
retryError,
currentResolution.pendingPayload,
currentResolution.pendingRuleId
);
showAlert(
'schedule-conflict-alert',
'Один конфликт устранён, но найдено ещё одно занятое место. Измените следующее правило и повторите сохранение.',
'error'
);
return;
}
throw retryError;
}
} catch (error) {
if (isScheduleRuleConflictError(error)) {
showAlert(
'schedule-conflict-alert',
`Выбранное место для переносимого правила тоже занято: ${ruleTitle(error.data.conflictRule)}. Выберите другой день, пару, преподавателя или аудиторию.`,
'error'
);
return;
}
showAlert('schedule-conflict-alert', error.message || 'Ошибка разрешения конфликта', 'error');
} finally {
if (conflictSubmitButton) {
conflictSubmitButton.disabled = false;
conflictSubmitButton.textContent = previousButtonText || 'Сохранить и повторить';
}
}
}
function renderConflictSlots(rule) {
const slots = rule.slots || [];
if (!conflictSlotsContainer) return;
if (!slots.length) {
conflictSlotsContainer.innerHTML = '<div class="loading-row">У конфликтующего правила нет слотов</div>';
return;
}
conflictSlotsContainer.innerHTML = slots.map((slot, index) => {
const fixedParts = [
slot.lessonTypeName,
slot.lessonFormat,
slotSubgroupNames(slot).join(', ')
].filter(Boolean);
return `
<section class="schedule-conflict-slot-row" data-index="${index}">
<div class="schedule-conflict-slot-fixed">
<span>Слот ${index + 1}</span>
<strong>${escapeHtml(fixedParts.join(' · ') || 'Занятие')}</strong>
<small>${escapeHtml(conflictSlotCurrentText(slot))}</small>
</div>
<div class="form-group"><label>День</label><select class="conflict-slot-day" required>${options(DAY_OPTIONS, slot.dayOfWeek)}</select></div>
<div class="form-group"><label>Чётность</label><select class="conflict-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="conflict-slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="conflict-slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="conflict-slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
</section>
`;
}).join('');
}
function readConflictSlots(validate) {
const conflictRule = pendingConflictResolution?.conflictRule;
const rows = Array.from(conflictSlotsContainer?.querySelectorAll('.schedule-conflict-slot-row') || []);
return rows.map((row, index) => {
const original = conflictRule?.slots?.[index] || {};
const slot = {
id: original.id || null,
dayOfWeek: Number(row.querySelector('.conflict-slot-day').value),
parity: row.querySelector('.conflict-slot-parity').value,
timeSlotId: Number(row.querySelector('.conflict-slot-time').value),
subgroupId: original.subgroupId || null,
subgroupIds: slotSubgroupIds(original),
teacherId: Number(row.querySelector('.conflict-slot-teacher').value),
classroomId: Number(row.querySelector('.conflict-slot-classroom').value),
lessonTypeId: Number(original.lessonTypeId),
lessonFormat: original.lessonFormat || 'Очно'
};
if (validate && (!slot.dayOfWeek || !slot.parity || !slot.timeSlotId
|| !slot.teacherId || !slot.classroomId || !slot.lessonTypeId || !slot.lessonFormat)) {
throw new Error(`Заполните слот ${index + 1} конфликтующего правила`);
}
return slot;
});
}
function buildRulePayloadFromRule(rule, slots) {
const groupIds = (rule.groupIds || []).map(Number).filter(Boolean);
if (!rule.id) throw new Error('Конфликтующее правило не найдено');
if (!rule.subjectId) throw new Error('У конфликтующего правила не указана дисциплина');
if (!rule.semesterId) throw new Error('У конфликтующего правила не указан семестр');
if (!groupIds.length) throw new Error('У конфликтующего правила нет групп');
if (!slots.length) throw new Error('У конфликтующего правила нет слотов');
return {
id: Number(rule.id),
subjectId: Number(rule.subjectId),
semesterId: Number(rule.semesterId),
lectureAcademicHours: Number(rule.lectureAcademicHours || 0),
laboratoryAcademicHours: Number(rule.laboratoryAcademicHours || 0),
practiceAcademicHours: Number(rule.practiceAcademicHours || 0),
lectureStartWeek: Number(rule.lectureStartWeek || 1),
laboratoryStartWeek: Number(rule.laboratoryStartWeek || 1),
practiceStartWeek: Number(rule.practiceStartWeek || 1),
groupIds,
slots
};
}
function isScheduleRuleConflictError(error) {
return error?.status === 409 && Boolean(error.data?.conflictRule);
}
function clonePlain(value) {
return JSON.parse(JSON.stringify(value));
}
function ruleDraftTitle(payload) {
const subject = subjects.find(item => String(item.id) === String(payload.subjectId));
return subject?.name || `Правило по дисциплине ${payload.subjectId}`;
}
function ruleTitle(rule) {
return rule?.subjectName || ruleDraftTitle(rule || {});
}
function ruleDraftMeta(payload) {
const groupNames = (payload.groupIds || []).map(groupNameById).join(', ') || 'группы не выбраны';
return [semesterTextById(payload.semesterId), groupNames, typeLoadText(payload)]
.filter(Boolean)
.join(' · ');
}
function ruleMeta(rule) {
const groupNames = (rule.groupNames || []).join(', ')
|| (rule.groupIds || []).map(groupNameById).join(', ')
|| 'группы не выбраны';
return [formatSemester(rule), groupNames, typeLoadText(rule)]
.filter(Boolean)
.join(' · ');
}
function typeLoadText(rule) {
return LESSON_TYPE_LIMITS
.map(config => {
const hours = Number(rule?.[config.hoursField] || 0);
const startWeek = Number(rule?.[config.startWeekField] || 1);
return hours > 0 ? `${config.label}: ${hours} ч, с ${startWeek} недели` : '';
})
.filter(Boolean)
.join('; ');
}
function semesterTextById(semesterId) {
const semester = semesterById(semesterId);
return semester ? shortSemesterLabel(semester) : 'семестр не выбран';
}
function conflictSlotCurrentText(slot) {
const parity = PARITY_LABELS[slot.parity] || slot.parity || '-';
return [
slot.dayName,
slot.timeSlotLabel,
parity,
slot.teacherName,
slot.classroomName
].filter(Boolean).join(' · ');
}
function buildRulePayload() {
const groupIds = selectedRuleGroupIds();
const slots = readRuleSlots(true);

View File

@@ -91,6 +91,71 @@
</form>
</div>
<div class="modal-overlay schedule-conflict-modal" id="schedule-conflict-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-conflict-card">
<button type="button" class="modal-close" id="schedule-conflict-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Конфликт слота</span>
<h2>Освободите место для нового правила</h2>
<p id="schedule-conflict-summary">Ранее созданное правило занимает выбранное время, преподавателя или аудиторию.</p>
</div>
<div class="schedule-conflict-rules">
<section class="schedule-conflict-rule-box">
<span>Новое правило</span>
<strong id="schedule-conflict-new-rule">-</strong>
<small id="schedule-conflict-new-rule-meta">-</small>
</section>
<section class="schedule-conflict-rule-box schedule-conflict-rule-box-active">
<span>Нужно изменить</span>
<strong id="schedule-conflict-existing-rule">-</strong>
<small id="schedule-conflict-existing-rule-meta">-</small>
</section>
</div>
<form id="schedule-conflict-form" class="schedule-conflict-form" novalidate>
<div class="schedule-conflict-slots" id="schedule-conflict-slots"></div>
<div class="form-alert" id="schedule-conflict-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-conflict-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-conflict-submit">Сохранить и повторить</button>
</div>
</form>
</div>
</div>
<div class="schedule-visual-context-menu" id="schedule-visual-context-menu" hidden>
<button type="button" data-action="edit-rule">Открыть правило</button>
<button type="button" data-action="move-slot">Изменить день и пару</button>
<button type="button" data-action="delete-rule" class="danger">Удалить правило</button>
</div>
<div class="modal-overlay schedule-move-slot-modal" id="schedule-move-slot-modal" role="dialog" aria-modal="true" aria-hidden="true">
<div class="modal-content schedule-move-slot-card">
<button type="button" class="modal-close" id="schedule-move-slot-close" aria-label="Закрыть окно">&times;</button>
<div class="schedule-conflict-head">
<span class="schedule-conflict-kicker">Перенос слота</span>
<h2 id="schedule-move-slot-title">Изменить день и пару</h2>
<p id="schedule-move-slot-summary">Будут изменены только день недели и базовая пара выбранного слота.</p>
</div>
<form id="schedule-move-slot-form" class="schedule-move-slot-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="schedule-move-slot-day">День</label>
<select id="schedule-move-slot-day" required></select>
</div>
<div class="form-group">
<label for="schedule-move-slot-time">Пара</label>
<select id="schedule-move-slot-time" required></select>
</div>
</div>
<div class="form-alert" id="schedule-move-slot-alert" role="alert"></div>
<div class="schedule-conflict-actions">
<button type="button" class="btn btn-md btn-ghost" id="schedule-move-slot-cancel">Отмена</button>
<button type="submit" class="btn btn-md btn-primary" id="schedule-move-slot-submit">Сохранить перенос</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header-row">
<h2>Правила динамического расписания</h2>