1 Задача

This commit is contained in:
dipatrik10
2026-08-06 01:10:33 +03:00
parent 28268a38c0
commit 5276729a69
33 changed files with 3569 additions and 19 deletions

View File

@@ -16,6 +16,12 @@ import {
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
const absenceForm = document.getElementById('teacher-absence-form');
const absenceList = document.getElementById('teacher-absence-list');
const absenceStatus = document.getElementById('teacher-absence-status');
const absenceStart = document.getElementById('teacher-absence-start');
const absenceEnd = document.getElementById('teacher-absence-end');
const absenceReason = document.getElementById('teacher-absence-reason');
let weekStart = startOfWeek(new Date());
document.getElementById('logout-link').addEventListener('click', async (event) => {
@@ -44,7 +50,92 @@ import {
}
});
document.getElementById('absence-form-toggle').addEventListener('click', event => {
const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true';
event.currentTarget.setAttribute('aria-expanded', String(!expanded));
event.currentTarget.textContent = expanded ? 'Новая заявка' : 'Скрыть форму';
absenceForm.hidden = expanded;
});
absenceForm.addEventListener('submit', submitAbsence);
absenceList.addEventListener('click', cancelAbsence);
const todayIso = toIsoDate(new Date());
absenceStart.value = todayIso;
absenceEnd.value = todayIso;
loadSchedule();
loadAbsences();
async function loadAbsences() {
absenceList.innerHTML = '<div class="teacher-absence-empty">Загрузка заявок...</div>';
try {
const items = await fetchJson('/api/teacher-absences');
absenceList.innerHTML = items.length ? items.map(item => `
<article class="teacher-absence-item">
<span class="teacher-absence-marker ${absenceStatusClass(item.status)}"></span>
<div>
<strong>${escapeHtml(formatIsoDate(item.startDate))} — ${escapeHtml(formatIsoDate(item.endDate))}</strong>
<p>${escapeHtml(item.reason || 'Причина не указана')}</p>
</div>
<span class="teacher-absence-badge ${absenceStatusClass(item.status)}">${escapeHtml(absenceStatusLabel(item.status))}</span>
${item.status === 'PENDING' ? `<button class="btn btn-sm btn-danger-subtle" type="button" data-cancel-absence="${item.id}">Отменить</button>` : ''}
</article>
`).join('') : '<div class="teacher-absence-empty">Заявок об отсутствии пока нет</div>';
} catch (error) {
absenceList.innerHTML = `<div class="teacher-absence-empty error">${escapeHtml(error.message)}</div>`;
}
}
async function submitAbsence(event) {
event.preventDefault();
showAbsenceStatus('', false);
if (!absenceStart.value || !absenceEnd.value || !absenceReason.value.trim()) {
showAbsenceStatus('Заполните период и причину отсутствия', true);
return;
}
if (absenceEnd.value < absenceStart.value) {
showAbsenceStatus('Дата окончания не может быть раньше даты начала', true);
return;
}
try {
await fetchJson('/api/teacher-absences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
startDate: absenceStart.value,
endDate: absenceEnd.value,
reason: absenceReason.value.trim()
})
});
absenceReason.value = '';
showAbsenceStatus('Заявка отправлена кафедре', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
}
}
async function cancelAbsence(event) {
const button = event.target.closest('[data-cancel-absence]');
if (!button) return;
button.disabled = true;
try {
await fetchJson(`/api/teacher-absences/${encodeURIComponent(button.dataset.cancelAbsence)}`, {
method: 'DELETE'
});
showAbsenceStatus('Заявка отменена', false);
await loadAbsences();
} catch (error) {
showAbsenceStatus(error.message, true);
button.disabled = false;
}
}
function showAbsenceStatus(message, isError) {
absenceStatus.textContent = message || '';
absenceStatus.classList.toggle('error', Boolean(isError));
absenceStatus.classList.toggle('success', Boolean(message) && !isError);
}
async function loadSchedule() {
const startDate = toIsoDate(weekStart);
@@ -185,8 +276,8 @@ import {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetchWithAuth(url);
async function fetchJson(url, options = {}) {
const response = await fetchWithAuth(url, options);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
@@ -225,6 +316,25 @@ import {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatIsoDate(value) {
const [year, month, day] = String(value || '').split('-');
return year && month && day ? `${day}.${month}.${year}` : '—';
}
function absenceStatusLabel(status) {
return {
PENDING: 'На подтверждении',
APPROVED: 'Согласовано',
REJECTED: 'Отклонено',
RESOLVED: 'Замены выполнены',
CANCELLED: 'Отменено'
}[status] || status || '—';
}
function absenceStatusClass(status) {
return `is-${String(status || '').toLowerCase()}`;
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);

View File

@@ -41,6 +41,35 @@
</section>
<div class="status" id="status-line"></div>
<section class="teacher-absence-panel">
<div class="teacher-absence-heading">
<div>
<span>Изменение планов</span>
<h2>Сообщить об отсутствии</h2>
<p>Заявка поступит кафедре на подтверждение. После согласования учебный отдел сможет подобрать замены.</p>
</div>
<button class="btn btn-md btn-secondary" id="absence-form-toggle" type="button" aria-expanded="false">
Новая заявка
</button>
</div>
<form class="teacher-absence-form" id="teacher-absence-form" hidden>
<label>
<span>Начало</span>
<input class="date-input" id="teacher-absence-start" type="date" required>
</label>
<label>
<span>Окончание</span>
<input class="date-input" id="teacher-absence-end" type="date" required>
</label>
<label class="teacher-absence-reason">
<span>Причина</span>
<input id="teacher-absence-reason" type="text" maxlength="2000" placeholder="Например, командировка" required>
</label>
<button class="btn btn-md btn-primary" type="submit">Отправить кафедре</button>
</form>
<div class="teacher-absence-status" id="teacher-absence-status"></div>
<div class="teacher-absence-list" id="teacher-absence-list" aria-live="polite"></div>
</section>
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>

View File

@@ -2,6 +2,178 @@
box-sizing: border-box;
}
/* Реестр собственных отсутствий преподавателя */
.teacher-absence-panel {
margin-bottom: 1.25rem;
padding: 1.25rem;
border: 1px solid color-mix(in srgb, #f59e0b 28%, var(--border));
border-radius: 18px;
background: linear-gradient(125deg, color-mix(in srgb, #f59e0b 9%, transparent), transparent 52%), var(--card);
}
.teacher-absence-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.teacher-absence-heading span {
color: #d97706;
font-size: 0.7rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.teacher-absence-heading h2 {
margin: 0.25rem 0;
font-size: 1.2rem;
}
.teacher-absence-heading p {
max-width: 720px;
margin: 0;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.5;
}
.teacher-absence-form {
display: grid;
grid-template-columns: 170px 170px minmax(260px, 1fr) auto;
gap: 0.75rem;
align-items: end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.teacher-absence-form[hidden] {
display: none;
}
.teacher-absence-form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
}
.teacher-absence-form input {
min-height: 42px;
padding: 0 0.75rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card);
color: var(--text);
font: inherit;
}
.teacher-absence-status {
min-height: 1.2rem;
margin-top: 0.7rem;
font-size: 0.8rem;
}
.teacher-absence-status.error { color: #ef4444; }
.teacher-absence-status.success { color: #10b981; }
.teacher-absence-list {
display: grid;
gap: 0.55rem;
margin-top: 0.35rem;
}
.teacher-absence-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 0.7rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 12px;
background: color-mix(in srgb, var(--card) 82%, transparent);
}
.teacher-absence-item p {
margin: 0.2rem 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.teacher-absence-marker {
width: 8px;
height: 38px;
border-radius: 999px;
background: #94a3b8;
}
.teacher-absence-marker.is-pending { background: #f59e0b; }
.teacher-absence-marker.is-approved { background: #fb7185; }
.teacher-absence-marker.is-resolved { background: #10b981; }
.teacher-absence-badge {
padding: 0.3rem 0.6rem;
border: 1px solid currentColor;
border-radius: 999px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.teacher-absence-badge.is-pending { color: #d97706; }
.teacher-absence-badge.is-approved { color: #e11d48; }
.teacher-absence-badge.is-resolved { color: #059669; }
.teacher-absence-empty {
padding: 0.75rem;
color: var(--muted);
text-align: center;
font-size: 0.82rem;
}
.teacher-absence-empty.error { color: #ef4444; }
@media (max-width: 900px) {
.teacher-absence-form {
grid-template-columns: 1fr 1fr;
}
.teacher-absence-reason {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) {
.teacher-absence-heading,
.teacher-absence-form {
grid-template-columns: 1fr;
}
.teacher-absence-heading {
align-items: stretch;
flex-direction: column;
}
.teacher-absence-reason {
grid-column: auto;
}
.teacher-absence-item {
grid-template-columns: auto minmax(0, 1fr);
}
.teacher-absence-badge,
.teacher-absence-item button {
grid-column: 2;
justify-self: start;
}
}
:root {
--bg: #f5f6f8;
--panel: #ffffff;