589 lines
28 KiB
HTML
589 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>
|
||
.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>
|