ii-agent-soglasovanie-otpusk/index.html

589 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>
.kt-ai-app { min-height: 100vh; background: var(--kt-ai-bg); color: var(--kt-ai-fg); }
.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; }
.sidebar-brand { display: flex; align-items: center; gap: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-6); }
.sidebar-brand svg { width: 22px; height: 22px; color: var(--kt-ai-primary); }
.sidebar-brand span { font-weight: var(--kt-ai-weight-semibold); font-size: 15px; }
.sidebar-nav { display: flex; flex-direction: column; gap: var(--kt-ai-space-1); }
.nav-item { display: flex; align-items: center; gap: var(--kt-ai-space-3); padding: var(--kt-ai-space-2) var(--kt-ai-space-3); border-radius: var(--kt-ai-radius-lg); color: var(--kt-ai-fg-muted); text-decoration: none; font-size: 14px; transition: all 0.15s; }
.nav-item:hover { background: var(--kt-ai-bg-hover); color: var(--kt-ai-fg); }
.nav-item.active { background: var(--kt-ai-bg-active); color: var(--kt-ai-fg); font-weight: var(--kt-ai-weight-medium); }
.nav-item svg { width: 18px; height: 18px; }
.nav-label { 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); }
.main-content { margin-left: 260px; padding: var(--kt-ai-space-6) var(--kt-ai-space-8); }
.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; }
.topbar-title p { font-size: 13px; color: var(--kt-ai-fg-muted); margin: 4px 0 0; }
.topbar-actions { display: flex; align-items: center; gap: var(--kt-ai-space-3); }
.kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
.kpi-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-5); }
.kpi-value { font-size: 28px; font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-fg); line-height: 1; margin-bottom: var(--kt-ai-space-2); }
.kpi-label { font-size: 13px; color: var(--kt-ai-fg-muted); margin-bottom: var(--kt-ai-space-1); }
.kpi-hint { font-size: 11px; color: var(--kt-ai-fg-faint); }
.table-wrap { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); 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); }
.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; }
.entity-name { font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg); }
.entity-meta { font-size: 11px; color: var(--kt-ai-fg-faint); margin-top: 2px; }
.text-right { text-align: right; }
.status-pill { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: var(--kt-ai-radius-full); font-size: 11px; font-weight: var(--kt-ai-weight-medium); }
.status-new { background: #fef3c7; color: #92400e; }
.status-approved { background: #d1fae5; color: #065f46; }
.status-rejected { background: #fee2e2; color: #991b1b; }
.status-pending { background: #e0e7ff; color: #3730a3; }
.btn-group { display: flex; gap: var(--kt-ai-space-2); }
.btn-sm { padding: 6px 12px; font-size: 12px; border-radius: var(--kt-ai-radius-lg); border: none; cursor: pointer; font-weight: var(--kt-ai-weight-medium); transition: all 0.12s; }
.btn-primary { background: var(--kt-ai-primary); color: white; }
.btn-primary:hover { opacity: 0.9; }
.btn-ghost { background: transparent; color: var(--kt-ai-fg-muted); border: 1px solid var(--kt-ai-border); }
.btn-ghost:hover { background: var(--kt-ai-bg-hover); color: var(--kt-ai-fg); }
.btn-danger { background: #fee2e2; color: #991b1b; }
.btn-danger:hover { background: #fecaca; }
.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: transparent; color: var(--kt-ai-fg-muted); cursor: pointer; transition: all 0.12s; }
.filter-chip:hover { background: var(--kt-ai-bg-hover); }
.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); }
.empty-state svg { width: 48px; height: 48px; margin-bottom: var(--kt-ai-space-4); opacity: 0.5; }
.empty-state h3 { font-size: 16px; font-weight: var(--kt-ai-weight-medium); margin-bottom: var(--kt-ai-space-2); color: var(--kt-ai-fg); }
.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-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: var(--kt-ai-space-5); }
.drawer-header h2 { font-size: 18px; font-weight: var(--kt-ai-weight-semibold); margin: 0; }
.drawer-close { background: none; border: none; font-size: 24px; color: var(--kt-ai-fg-muted); cursor: pointer; padding: 0; line-height: 1; }
.drawer-close:hover { color: var(--kt-ai-fg); }
.drawer-section { margin-bottom: var(--kt-ai-space-5); }
.drawer-section h3 { 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); }
.info-row { display: flex; justify-content: space-between; padding: var(--kt-ai-space-3) 0; border-bottom: 1px solid var(--kt-ai-divider); font-size: 14px; }
.info-row:last-child { border-bottom: none; }
.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; }
.rule-check svg { width: 18px; height: 18px; flex-shrink: 0; }
.rule-check.ok { color: var(--kt-ai-fg); }
.rule-check.fail { color: #991b1b; }
.rule-check svg.ok { color: #059669; }
.rule-check svg.fail { color: #dc2626; }
.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; }
.toast.success { border-left: 4px solid #059669; }
.toast.error { border-left: 4px solid #dc2626; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
</style>
</head>
<body class="kt-ai-app">
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span>Согласование отпусков</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active">
<svg 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"/>
</svg>
<span>Заявки</span>
</a>
<a href="#" class="nav-item" onclick="showGraph(); return false;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>
</svg>
<span>График отпусков</span>
</a>
<div class="nav-label">Сотрудники</div>
<div id="team-list" style="display:flex;flex-direction:column;gap:2px;"></div>
</nav>
</aside>
<main class="main-content">
<div class="topbar">
<div class="topbar-title">
<h1>Заявки на отпуск</h1>
<p>Согласование и учёт ежегодных отпусков отдела</p>
</div>
<div class="topbar-actions">
<button class="kt-ai-btn" data-variant="ghost" onclick="resetData()">
<svg class="kt-icon" style="width:16px;height:16px;margin-right:6px"><use href="#rotateCcw"/></svg>
Сбросить
</button>
<div 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>
Новая заявка
</div>
</div>
</div>
<div class="kpi-strip">
<div class="kpi-card">
<div class="kpi-value" id="kpi-total">0</div>
<div class="kpi-label">Всего заявок</div>
<div class="kpi-hint">за 2026 год</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-pending">0</div>
<div class="kpi-label">Требуют решения</div>
<div class="kpi-hint">ожидают согласования</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-approved">0</div>
<div class="kpi-label">Согласовано</div>
<div class="kpi-hint">в этом месяце</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpi-conflicts">0</div>
<div class="kpi-label">Конфликтов</div>
<div class="kpi-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="table-wrap">
<div class="table-header">
<div>Сотрудник</div>
<div>Даты</div>
<div>Тип</div>
<div>Остаток</div>
<div>Статус</div>
<div class="text-right">Действия</div>
</div>
<div id="requests-table"></div>
</div>
</main>
<div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
<div class="drawer" id="drawer">
<div class="drawer-header">
<h2 id="drawer-title">Заявка</h2>
<button class="drawer-close" onclick="closeDrawer()">×</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:8px;padding:6px 8px;font-size:13px;color:var(--kt-ai-fg-muted)">
<div style="width:24px;height:24px;border-radius:50%;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 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 viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3>Нет заявок</h3>
<p>Заявок с выбранным статусом пока нет</p>
</div>
`;
return;
}
container.innerHTML = filtered.map(req => {
const emp = employees.find(e => e.id === req.employeeId);
const statusClass = `status-${req.status}`;
const statusLabels = { new: 'Новая', pending: 'На согласовании', approved: 'Согласовано', rejected: 'Отклонено' };
return `
<div class="table-row" onclick="openDrawer(${req.id})" style="cursor:pointer">
<div>
<div class="entity-name">${req.employeeName}</div>
<div class="entity-meta">${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="status-pill ${statusClass}">${statusLabels[req.status]}</span></div>
<div class="text-right">
${req.status === 'new' || req.status === 'pending' ? `
<div class="btn-group" onclick="event.stopPropagation()">
<button class="btn-sm btn-danger" onclick="rejectRequest(${req.id})">✕</button>
<button class="btn-sm btn-primary" onclick="approveRequest(${req.id})">✓</button>
</div>
` : '<span style="color:var(--kt-ai-fg-faint);font-size:18px">›</span>'}
</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);
document.getElementById('drawer-title').textContent = `Заявка №${req.id}`;
document.getElementById('drawer-content').innerHTML = `
<div class="drawer-section">
<h3>Основная информация</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>Проверка правил</h3>
<div class="rule-check ${emp && emp.vacationDays >= req.days ? 'ok' : 'fail'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="${emp && emp.vacationDays >= req.days ? 'ok' : 'fail'}">
${emp && emp.vacationDays >= req.days ? '<path d="M20 6L9 17l-5-5"/>' : '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'}
</svg>
<span>Достаточно дней отпуска (${req.days} из ${emp ? emp.vacationDays : 0})</span>
</div>
<div class="rule-check ${!hasConflict ? 'ok' : 'fail'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="${!hasConflict ? 'ok' : 'fail'}">
${!hasConflict ? '<path d="M20 6L9 17l-5-5"/>' : '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'}
</svg>
<span>Лимит simultaneity (≤2 человека) ${hasConflict ? `— конфликт с: ${overlap.map(o => o.employeeName).join(', ')}` : ''}</span>
</div>
<div class="rule-check ${!seniorOnVacation ? 'ok' : 'fail'}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="${!seniorOnVacation ? 'ok' : 'fail'}">
${!seniorOnVacation ? '<path d="M20 6L9 17l-5-5"/>' : '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'}
</svg>
<span>Наличие дежурного старшего ${seniorOnVacation ? '— все старшие в отпуске' : ''}</span>
</div>
</div>
${req.note ? `
<div class="drawer-section">
<h3>Примечание</h3>
<p style="font-size:14px;color:var(--kt-ai-fg-muted);line-height:1.6">${req.note}</p>
</div>
` : ''}
${req.rejectReason ? `
<div class="drawer-section">
<h3 style="color:#991b1b">Причина отказа</h3>
<p style="font-size:14px;color:#991b1b;line-height:1.6">${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}`;
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="rotateCcw" 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>
</svg>
</body>
</html>