561 lines
28 KiB
HTML
561 lines
28 KiB
HTML
<!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>
|