ii-agent-soglasovanie-otpusk/index.html

561 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Согласование отпусков</title>
<link rel="stylesheet" href="./design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="./design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="./design-system/kt-ai-components.css">
<link rel="stylesheet" href="./design-system/kt-ai-page.css">
<style>
.app-layout { display: flex; min-height: 100vh; background: var(--kt-ai-bg); }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: 260px; background: var(--kt-ai-bg-soft); border-right: 1px solid var(--kt-ai-border); padding: var(--kt-ai-space-4); display: flex; flex-direction: column; }
.main-content { margin-left: 260px; padding: var(--kt-ai-space-6) var(--kt-ai-space-8); flex: 1; }
.topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--kt-ai-space-6); }
.topbar-title h1 { font-size: 20px; font-weight: var(--kt-ai-weight-semibold); margin: 0; color: var(--kt-ai-fg); }
.topbar-title p { font-size: 13px; color: var(--kt-ai-fg-muted); margin: 4px 0 0; }
.table-container { border-radius: var(--kt-ai-radius-2xl); overflow: hidden; }
.table-header { display: grid; grid-template-columns: 2fr 1.5fr 1.2fr 1fr 1fr 120px; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) var(--kt-ai-space-5); border-bottom: 1px solid var(--kt-ai-divider); font-size: 12px; font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg-muted); background: var(--kt-ai-bg-soft); }
.table-row { display: grid; grid-template-columns: 2fr 1.5fr 1.2fr 1fr 1fr 120px; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) var(--kt-ai-space-5); border-bottom: 1px solid var(--kt-ai-divider); align-items: center; font-size: 13px; transition: background 0.12s; }
.table-row:hover { background: var(--kt-ai-bg-hover); }
.table-row:last-child { border-bottom: none; }
.text-right { text-align: right; }
.btn-group { display: flex; gap: var(--kt-ai-space-2); }
.filter-bar { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-5); flex-wrap: wrap; }
.filter-chip { padding: 6px 14px; border-radius: var(--kt-ai-radius-full); font-size: 13px; border: 1px solid var(--kt-ai-border); background: var(--kt-ai-bg); color: var(--kt-ai-fg-muted); cursor: pointer; transition: all 0.12s; }
.filter-chip:hover { background: var(--kt-ai-bg-hover); color: var(--kt-ai-fg); }
.filter-chip.active { background: var(--kt-ai-primary); color: white; border-color: var(--kt-ai-primary); }
.empty-state { text-align: center; padding: var(--kt-ai-space-12) var(--kt-ai-space-6); color: var(--kt-ai-fg-muted); }
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: none; z-index: 100; }
.drawer-overlay.open { display: block; }
.drawer { position: fixed; right: -480px; top: 0; bottom: 0; width: 480px; background: var(--kt-ai-bg); border-left: 1px solid var(--kt-ai-border); padding: var(--kt-ai-space-6); transition: right 0.25s; z-index: 101; overflow-y: auto; }
.drawer.open { right: 0; }
.drawer-section { margin-bottom: var(--kt-ai-space-5); padding-bottom: var(--kt-ai-space-4); border-bottom: 1px solid var(--kt-ai-divider); }
.drawer-section:last-child { border-bottom: none; }
.info-row { display: flex; justify-content: space-between; padding: var(--kt-ai-space-3) 0; font-size: 14px; }
.info-label { color: var(--kt-ai-fg-muted); }
.info-value { font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg); }
.rule-check { display: flex; align-items: center; gap: var(--kt-ai-space-3); padding: var(--kt-ai-space-3) 0; font-size: 14px; }
.rule-check svg { width: 18px; height: 18px; flex-shrink: 0; }
.toast { position: fixed; bottom: var(--kt-ai-space-6); right: var(--kt-ai-space-6); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4) var(--kt-ai-space-5); box-shadow: 0 10px 40px rgba(0,0,0,0.15); display: none; z-index: 200; max-width: 400px; }
.toast.show { display: block; animation: slideIn 0.25s ease; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div style="display:flex;align-items:center;gap:var(--kt-ai-space-3);margin-bottom:var(--kt-ai-space-6)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px;color:var(--kt-ai-primary)">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<strong style="font-size:15px;color:var(--kt-ai-fg)">Согласование отпусков</strong>
</div>
<nav style="display:flex;flex-direction:column;gap:var(--kt-ai-space-1)">
<a href="#" class="kt-ai-btn" data-variant="ghost" style="justify-content:flex-start">
<svg class="kt-icon" style="width:18px;height:18px;margin-right:var(--kt-ai-space-3)"><use href="#layout-grid"/></svg>
<span>Заявки</span>
</a>
<a href="#" class="kt-ai-btn" data-variant="ghost" style="justify-content:flex-start" onclick="showGraph();return false;">
<svg class="kt-icon" style="width:18px;height:18px;margin-right:var(--kt-ai-space-3)"><use href="#bar-chart-3"/></svg>
<span>График отпусков</span>
</a>
<div style="font-size:11px;color:var(--kt-ai-fg-faint);text-transform:uppercase;letter-spacing:0.05em;margin:var(--kt-ai-space-4) 0 var(--kt-ai-space-2);font-weight:var(--kt-ai-weight-medium)">Сотрудники</div>
<div id="team-list"></div>
</nav>
</aside>
<main class="main-content">
<div class="topbar">
<div class="topbar-title">
<h1>Заявки на отпуск</h1>
<p>Согласование и учёт ежегодных отпусков отдела</p>
</div>
<div style="display:flex;gap:var(--kt-ai-space-3)">
<button class="kt-ai-btn" data-variant="ghost" onclick="resetData()">
<svg class="kt-icon" style="width:16px;height:16px;margin-right:6px"><use href="#rotate-ccw"/></svg>
Сбросить
</button>
<button class="kt-ai-btn" data-variant="primary" onclick="createDemoRequest()">
<svg class="kt-icon" style="width:16px;height:16px;margin-right:6px"><use href="#plus"/></svg>
Новая заявка
</button>
</div>
</div>
<div class="kt-ai-kpi-strip">
<div class="kt-ai-kpi">
<div class="value" id="kpi-total">0</div>
<div class="label">Всего заявок</div>
<div class="hint">за 2026 год</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-pending">0</div>
<div class="label">Требуют решения</div>
<div class="hint">ожидают согласования</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-approved">0</div>
<div class="label">Согласовано</div>
<div class="hint">в этом месяце</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-conflicts">0</div>
<div class="label">Конфликтов</div>
<div class="hint">нарушений правил</div>
</div>
</div>
<div class="filter-bar">
<button class="filter-chip active" data-filter="all">Все заявки</button>
<button class="filter-chip" data-filter="new">Новые</button>
<button class="filter-chip" data-filter="pending">На согласовании</button>
<button class="filter-chip" data-filter="approved">Согласовано</button>
<button class="filter-chip" data-filter="rejected">Отклонено</button>
</div>
<div class="kt-ai-card table-container">
<div class="table-header">
<div>Сотрудник</div>
<div>Даты</div>
<div>Тип</div>
<div class="text-right">Остаток</div>
<div>Статус</div>
<div class="text-right">Действия</div>
</div>
<div id="requests-table"></div>
</div>
</main>
</div>
<div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
<div class="drawer" id="drawer">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:var(--kt-ai-space-5)">
<h2 style="font-size:18px;font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-fg);margin:0" id="drawer-title">Заявка</h2>
<button class="kt-ai-btn" data-variant="ghost" onclick="closeDrawer()" style="padding:var(--kt-ai-space-1)">
<svg class="kt-icon" style="width:20px;height:20px"><use href="#x"/></svg>
</button>
</div>
<div id="drawer-content"></div>
</div>
<div class="toast" id="toast"></div>
<script>
let requests = [];
let employees = [];
let currentFilter = 'all';
const DEMO_EMPLOYEES = [
{ id: 1, name: 'Алиев Ержан', position: 'Руководитель отдела', isSenior: true, vacationDays: 24 },
{ id: 2, name: 'Иванова Мария', position: 'Ведущий специалист', isSenior: true, vacationDays: 18 },
{ id: 3, name: 'Петров Алексей', position: 'Специалист', isSenior: false, vacationDays: 15 },
{ id: 4, name: 'Смирнова Елена', position: 'Специалист', isSenior: true, vacationDays: 20 },
{ id: 5, name: 'Нурсултанов Азамат', position: 'Ведущий специалист', isSenior: false, vacationDays: 12 },
{ id: 6, name: 'Ким Юлия', position: 'Специалист', isSenior: false, vacationDays: 8 },
{ id: 7, name: 'Омаров Данияр', position: 'Специалист', isSenior: false, vacationDays: 22 },
{ id: 8, name: 'Волкова Наталья', position: 'Ведущий специалист', isSenior: false, vacationDays: 16 }
];
const DEMO_REQUESTS = [
{ id: 101, employeeId: 3, employeeName: 'Петров Алексей', startDate: '2026-09-15', endDate: '2026-09-28', type: 'Ежегодный', days: 14, status: 'new', createdAt: '2026-09-08T10:30:00', note: '' },
{ id: 102, employeeId: 6, employeeName: 'Ким Юлия', startDate: '2026-09-20', endDate: '2026-10-03', type: 'Ежегодный', days: 14, status: 'pending', createdAt: '2026-09-07T14:15:00', note: 'Согласовано устно' },
{ id: 103, employeeId: 2, employeeName: 'Иванова Мария', startDate: '2026-10-05', endDate: '2026-10-18', type: 'Ежегодный', days: 14, status: 'approved', createdAt: '2026-09-05T09:00:00', approvedAt: '2026-09-05T16:30:00', note: '' },
{ id: 104, employeeId: 7, employeeName: 'Омаров Данияр', startDate: '2026-09-25', endDate: '2026-10-08', type: 'Ежегодный', days: 14, status: 'new', createdAt: '2026-09-08T11:45:00', note: '' },
{ id: 105, employeeId: 5, employeeName: 'Нурсултанов Азамат', startDate: '2026-11-01', endDate: '2026-11-14', type: 'Учебный', days: 14, status: 'rejected', createdAt: '2026-09-03T13:20:00', rejectedAt: '2026-09-04T10:00:00', rejectReason: 'Период сдачи отчётности' },
{ id: 106, employeeId: 8, employeeName: 'Волкова Наталья', startDate: '2026-10-10', endDate: '2026-10-23', type: 'Ежегодный', days: 14, status: 'pending', createdAt: '2026-09-06T16:00:00', note: '' },
{ id: 107, employeeId: 1, employeeName: 'Алиев Ержан', startDate: '2026-12-01', endDate: '2026-12-14', type: 'Ежегодный', days: 14, status: 'new', createdAt: '2026-09-08T09:15:00', note: '' }
];
function init() {
const saved = localStorage.getItem('vacation_requests');
const savedEmployees = localStorage.getItem('vacation_employees');
if (saved) {
requests = JSON.parse(saved);
} else {
requests = [...DEMO_REQUESTS];
saveData();
}
if (savedEmployees) {
employees = JSON.parse(savedEmployees);
} else {
employees = [...DEMO_EMPLOYEES];
saveEmployees();
}
renderTeamList();
renderRequests();
updateKPI();
setupFilters();
}
function saveData() {
localStorage.setItem('vacation_requests', JSON.stringify(requests));
}
function saveEmployees() {
localStorage.setItem('vacation_employees', JSON.stringify(employees));
}
function resetData() {
if (confirm('Сбросить все данные к демо-значениям?')) {
requests = [...DEMO_REQUESTS];
employees = [...DEMO_EMPLOYEES];
saveData();
saveEmployees();
renderTeamList();
renderRequests();
updateKPI();
showToast('Данные сброшены', 'success');
}
}
function renderTeamList() {
const container = document.getElementById('team-list');
container.innerHTML = employees.slice(0, 5).map(emp => `
<div style="display:flex;align-items:center;gap:var(--kt-ai-space-2);padding:var(--kt-ai-space-2);font-size:13px;color:var(--kt-ai-fg-muted)">
<div style="width:24px;height:24px;border-radius:var(--kt-ai-radius-full);background:var(--kt-ai-primary);color:white;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600">
${emp.name.split(' ').map(n=>n[0]).join('').slice(0,2)}
</div>
<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${emp.name.split(' ')[1] || emp.name}</span>
</div>
`).join('');
}
function getStatusVariant(status) {
const variants = {
new: { bg: 'var(--kt-ai-status-warn-bg)', color: 'var(--kt-ai-status-warn-fg)', label: 'Новая' },
pending: { bg: 'var(--kt-ai-status-info-bg)', color: 'var(--kt-ai-status-info-fg)', label: 'На согласовании' },
approved: { bg: 'var(--kt-ai-status-ok-bg)', color: 'var(--kt-ai-status-ok-fg)', label: 'Согласовано' },
rejected: { bg: 'var(--kt-ai-status-risk-bg)', color: 'var(--kt-ai-status-risk-fg)', label: 'Отклонено' }
};
return variants[status] || variants.new;
}
function renderRequests() {
const container = document.getElementById('requests-table');
let filtered = [...requests];
if (currentFilter === 'new') filtered = requests.filter(r => r.status === 'new');
else if (currentFilter === 'pending') filtered = requests.filter(r => r.status === 'pending');
else if (currentFilter === 'approved') filtered = requests.filter(r => r.status === 'approved');
else if (currentFilter === 'rejected') filtered = requests.filter(r => r.status === 'rejected');
filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<svg class="kt-icon" style="width:48px;height:48px;color:var(--kt-ai-fg-muted);opacity:0.5;margin-bottom:var(--kt-ai-space-4)"><use href="#inbox"/></svg>
<h3 style="font-size:16px;font-weight:var(--kt-ai-weight-medium);color:var(--kt-ai-fg);margin-bottom:var(--kt-ai-space-2)">Нет заявок</h3>
<p style="font-size:13px;color:var(--kt-ai-fg-muted)">Заявок с выбранным статусом пока нет</p>
</div>
`;
return;
}
container.innerHTML = filtered.map(req => {
const emp = employees.find(e => e.id === req.employeeId);
const variant = getStatusVariant(req.status);
return `
<div class="table-row" onclick="openDrawer(${req.id})" style="cursor:pointer">
<div>
<div style="font-weight:var(--kt-ai-weight-medium);color:var(--kt-ai-fg)">${req.employeeName}</div>
<div style="font-size:11px;color:var(--kt-ai-fg-faint);margin-top:2px">${emp ? emp.position : ''}</div>
</div>
<div>${formatDateRange(req.startDate, req.endDate)}</div>
<div>${req.type}</div>
<div class="text-right">${emp ? emp.vacationDays : 0} дн.</div>
<div><span class="kt-ai-chip" style="background:${variant.bg};color:${variant.color}">${variant.label}</span></div>
<div class="text-right">
${req.status === 'new' || req.status === 'pending' ? `
<div class="btn-group" onclick="event.stopPropagation()">
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="rejectRequest(${req.id})" title="Отклонить">
<svg class="kt-icon" style="width:14px;height:14px;color:var(--kt-ai-status-risk-fg)"><use href="#x"/></svg>
</button>
<button class="kt-ai-btn" data-variant="primary" data-size="sm" onclick="approveRequest(${req.id})" title="Согласовать">
<svg class="kt-icon" style="width:14px;height:14px"><use href="#check"/></svg>
</button>
</div>
` : `<svg class="kt-icon" style="width:18px;height:18px;color:var(--kt-ai-fg-faint)"><use href="#chevron-right"/></svg>`}
</div>
</div>
`;
}).join('');
}
function updateKPI() {
const total = requests.length;
const pending = requests.filter(r => r.status === 'new' || r.status === 'pending').length;
const now = new Date();
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
const approvedThisMonth = requests.filter(r => r.status === 'approved' && r.approvedAt && new Date(r.approvedAt) >= monthAgo).length;
const conflicts = checkAllConflicts();
document.getElementById('kpi-total').textContent = total;
document.getElementById('kpi-pending').textContent = pending;
document.getElementById('kpi-approved').textContent = approvedThisMonth;
document.getElementById('kpi-conflicts').textContent = conflicts;
}
function checkAllConflicts() {
const approved = requests.filter(r => r.status === 'approved');
let conflicts = 0;
for (let i = 0; i < approved.length; i++) {
for (let j = i + 1; j < approved.length; j++) {
if (datesOverlap(approved[i], approved[j])) {
conflicts++;
}
}
}
return conflicts;
}
function datesOverlap(r1, r2) {
return new Date(r1.startDate) <= new Date(r2.endDate) && new Date(r2.startDate) <= new Date(r1.endDate);
}
function setupFilters() {
document.querySelectorAll('.filter-chip').forEach(chip => {
chip.addEventListener('click', () => {
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
currentFilter = chip.dataset.filter;
renderRequests();
});
});
}
function openDrawer(id) {
const req = requests.find(r => r.id === id);
if (!req) return;
const emp = employees.find(e => e.id === req.employeeId);
const overlap = checkOverlaps(req);
const hasConflict = overlap.length > 0;
const seniorOnVacation = checkSeniorConflict(req);
const daysOk = emp && emp.vacationDays >= req.days;
document.getElementById('drawer-title').textContent = `Заявка №${req.id}`;
document.getElementById('drawer-content').innerHTML = `
<div class="drawer-section">
<h3 style="font-size:13px;font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-fg-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--kt-ai-space-3)">Основная информация</h3>
<div class="info-row">
<span class="info-label">Сотрудник</span>
<span class="info-value">${req.employeeName}</span>
</div>
<div class="info-row">
<span class="info-label">Должность</span>
<span class="info-value">${emp ? emp.position : ''}</span>
</div>
<div class="info-row">
<span class="info-label">Тип отпуска</span>
<span class="info-value">${req.type}</span>
</div>
<div class="info-row">
<span class="info-label">Период</span>
<span class="info-value">${formatDateRange(req.startDate, req.endDate)}</span>
</div>
<div class="info-row">
<span class="info-label">Количество дней</span>
<span class="info-value">${req.days} рабочих дней</span>
</div>
<div class="info-row">
<span class="info-label">Остаток дней</span>
<span class="info-value">${emp ? emp.vacationDays : 0} дн.</span>
</div>
</div>
<div class="drawer-section">
<h3 style="font-size:13px;font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-fg-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--kt-ai-space-3)">Проверка правил</h3>
<div class="rule-check" style="color:${daysOk ? 'var(--kt-ai-status-ok-fg)' : 'var(--kt-ai-status-risk-fg)'}">
<svg class="kt-icon" style="color:${daysOk ? 'var(--kt-ai-status-ok)' : 'var(--kt-ai-status-risk)'}"><use href="${daysOk ? '#check-circle' : '#x-circle'}"/></svg>
<span>Достаточно дней отпуска (${req.days} из ${emp ? emp.vacationDays : 0})</span>
</div>
<div class="rule-check" style="color:${!hasConflict ? 'var(--kt-ai-status-ok-fg)' : 'var(--kt-ai-status-risk-fg)'}">
<svg class="kt-icon" style="color:${!hasConflict ? 'var(--kt-ai-status-ok)' : 'var(--kt-ai-status-risk)'}"><use href="${!hasConflict ? '#check-circle' : '#x-circle'}"/></svg>
<span>Лимит simultaneity (≤2 человека) ${hasConflict ? `— конфликт с: ${overlap.map(o => o.employeeName).join(', ')}` : ''}</span>
</div>
<div class="rule-check" style="color:${!seniorOnVacation ? 'var(--kt-ai-status-ok-fg)' : 'var(--kt-ai-status-risk-fg)'}">
<svg class="kt-icon" style="color:${!seniorOnVacation ? 'var(--kt-ai-status-ok)' : 'var(--kt-ai-status-risk)'}"><use href="${!seniorOnVacation ? '#check-circle' : '#x-circle'}"/></svg>
<span>Наличие дежурного старшего ${seniorOnVacation ? '— все старшие в отпуске' : ''}</span>
</div>
</div>
${req.note ? `
<div class="drawer-section">
<h3 style="font-size:13px;font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-fg-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--kt-ai-space-3)">Примечание</h3>
<p style="font-size:14px;color:var(--kt-ai-fg-muted);line-height:1.6;margin:0">${req.note}</p>
</div>
` : ''}
${req.rejectReason ? `
<div class="drawer-section">
<h3 style="font-size:13px;font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-status-risk-fg);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:var(--kt-ai-space-3)">Причина отказа</h3>
<p style="font-size:14px;color:var(--kt-ai-status-risk-fg);line-height:1.6;margin:0">${req.rejectReason}</p>
</div>
` : ''}
<div style="margin-top:var(--kt-ai-space-6);display:flex;gap:var(--kt-ai-space-3)">
${req.status === 'new' || req.status === 'pending' ? `
<button class="kt-ai-btn" data-variant="danger" style="flex:1" onclick="rejectRequest(${req.id});closeDrawer()">Отклонить</button>
<button class="kt-ai-btn" data-variant="primary" style="flex:1" onclick="approveRequest(${req.id});closeDrawer()">Согласовать</button>
` : `
<button class="kt-ai-btn" data-variant="ghost" style="flex:1" onclick="closeDrawer()">Закрыть</button>
`}
</div>
`;
document.getElementById('drawer-overlay').classList.add('open');
document.getElementById('drawer').classList.add('open');
}
function closeDrawer() {
document.getElementById('drawer-overlay').classList.remove('open');
document.getElementById('drawer').classList.remove('open');
}
function approveRequest(id) {
const req = requests.find(r => r.id === id);
if (!req) return;
const emp = employees.find(e => e.id === req.employeeId);
const overlap = checkOverlaps(req);
const seniorOnVacation = checkSeniorConflict(req);
if (overlap.length > 0) {
if (!confirm(`⚠️ На эти даты уже запланирован отпуск сотрудника ${overlap[0].employeeName}. Продолжить?`)) return;
}
if (emp && emp.isSenior && seniorOnVacation) {
if (!confirm('⚠️ Все старшие сотрудники будут отсутствовать. Продолжить?')) return;
}
req.status = 'approved';
req.approvedAt = new Date().toISOString();
if (emp) {
emp.vacationDays = Math.max(0, emp.vacationDays - req.days);
}
saveData();
saveEmployees();
renderRequests();
updateKPI();
showToast(`Заявка №${id} согласована`, 'success');
}
function rejectRequest(id) {
const req = requests.find(r => r.id === id);
if (!req) return;
const reason = prompt('Причина отказа:', '');
if (reason === null) return;
req.status = 'rejected';
req.rejectedAt = new Date().toISOString();
req.rejectReason = reason || 'Без указания причины';
saveData();
renderRequests();
updateKPI();
showToast(`Заявка №${id} отклонена`, 'error');
}
function checkOverlaps(currentReq) {
const approved = requests.filter(r =>
r.status === 'approved' &&
r.id !== currentReq.id
);
return approved.filter(r => datesOverlap(currentReq, r));
}
function checkSeniorConflict(currentReq) {
const emp = employees.find(e => e.id === currentReq.employeeId);
if (!emp || !emp.isSenior) return false;
const seniors = employees.filter(e => e.isSenior && e.id !== currentReq.employeeId);
const approved = requests.filter(r => r.status === 'approved');
const seniorsOnVacation = seniors.filter(senior =>
approved.some(r => r.employeeId === senior.id && datesOverlap(currentReq, r))
);
return seniorsOnVacation.length === seniors.length;
}
function formatDateRange(start, end) {
const s = new Date(start);
const e = new Date(end);
const options = { day: 'numeric', month: 'short' };
return `${s.toLocaleDateString('ru-RU', options)} — ${e.toLocaleDateString('ru-RU', options)}`;
}
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast show ${type}`;
toast.style.borderLeft = `4px solid ${type === 'success' ? 'var(--kt-ai-status-ok)' : 'var(--kt-ai-status-risk)'}`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function createDemoRequest() {
const emp = employees[Math.floor(Math.random() * employees.length)];
const startOffset = Math.floor(Math.random() * 30) + 7;
const startDate = new Date();
startDate.setDate(startDate.getDate() + startOffset);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 13);
const newReq = {
id: Date.now(),
employeeId: emp.id,
employeeName: emp.name,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
type: 'Ежегодный',
days: 14,
status: 'new',
createdAt: new Date().toISOString(),
note: ''
};
requests.unshift(newReq);
saveData();
renderRequests();
updateKPI();
showToast('Создана демо-заявка', 'success');
}
function showGraph() {
showToast('График отпусков — в разработке', 'success');
}
init();
</script>
<svg style="display:none">
<symbol id="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></symbol>
<symbol id="rotate-ccw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></symbol>
<symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></symbol>
<symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></symbol>
<symbol id="chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
<symbol id="bar-chart-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></symbol>
<symbol id="inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v1"/><path d="M22 12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10"/><rect x="8" y="7" width="8" height="8"/></symbol>
<symbol id="check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></symbol>
<symbol id="x-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol>
</svg>
</body>
</html>