Files
yarmarka/templates/admin.html
2026-03-17 20:01:50 +03:00

1504 lines
55 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">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #eef5fa 0%, #e0eaf5 100%);
min-height: 100vh;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #0b1c34;
color: white;
padding: 20px 40px;
border-radius: 40px;
margin-bottom: 40px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.logo {
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
}
.logo i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.nav {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
transition: 0.2s;
}
.nav a:hover {
background: rgba(255,255,255,0.1);
}
.nav .active {
background: #3b82f6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: white;
border-radius: 30px;
padding: 25px;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
transition: 0.2s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(0,20,40,0.15);
}
.stat-card .label {
color: #4f7092;
font-size: 14px;
margin-bottom: 10px;
}
.stat-card .value {
font-size: 36px;
font-weight: 700;
color: #0b1c34;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 30px;
background: white;
padding: 10px;
border-radius: 50px;
flex-wrap: wrap;
}
.tab {
padding: 12px 24px;
border-radius: 40px;
cursor: pointer;
font-weight: 600;
transition: 0.2s;
border: none;
background: transparent;
font-size: 16px;
}
.tab:hover {
background: #eef4fa;
}
.tab.active {
background: #0b1c34;
color: white;
}
.content-card {
background: white;
border-radius: 40px;
padding: 30px;
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
}
.table-header h2 {
color: #0b1c34;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.table-header h2 i {
color: #3b82f6;
}
.search-box {
display: flex;
gap: 10px;
align-items: center;
}
.search-box input {
padding: 12px 20px;
border: 2px solid #dee9f5;
border-radius: 30px;
font-size: 14px;
width: 250px;
transition: 0.2s;
}
.search-box input:focus {
border-color: #3b82f6;
outline: none;
}
.table-container {
overflow-x: auto;
border-radius: 20px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
}
th {
text-align: left;
padding: 15px;
background: #f0f7ff;
color: #1f3f60;
font-weight: 600;
}
td {
padding: 15px;
border-bottom: 1px solid #dee9f5;
}
tr:hover td {
background: #f9fcff;
}
.badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
display: inline-block;
}
.badge.employee {
background: #e0f2e0;
color: #166534;
}
.badge.employer {
background: #dbeafe;
color: #1e40af;
}
.badge.admin {
background: #fef3c7;
color: #92400e;
}
.badge.active {
background: #d1fae5;
color: #065f46;
}
.badge.inactive {
background: #fee2e2;
color: #b91c1c;
}
.btn-icon {
padding: 8px 12px;
border: none;
border-radius: 20px;
cursor: pointer;
margin: 0 5px;
transition: 0.2s;
}
.btn-icon.delete {
background: #fee2e2;
color: #b91c1c;
}
.btn-icon.delete:hover {
background: #fecaca;
}
.btn-icon.edit {
background: #dbeafe;
color: #1e40af;
}
.btn-icon.edit:hover {
background: #bfdbfe;
}
.btn-icon.view {
background: #e0f2fe;
color: #0369a1;
}
.btn-icon.view:hover {
background: #bae6fd;
}
.tags-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
.tag-item {
background: #eef4fa;
padding: 8px 16px;
border-radius: 30px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.tag-item .count {
background: #3b82f6;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
}
.loading {
text-align: center;
padding: 40px;
color: #4f7092;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.6);
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
max-width: 600px;
width: 90%;
border-radius: 40px;
padding: 40px;
position: relative;
max-height: 80vh;
overflow-y: auto;
}
.modal-close {
position: absolute;
top: 20px;
right: 20px;
background: #eef4fa;
border: none;
width: 40px;
height: 40px;
border-radius: 20px;
font-size: 24px;
cursor: pointer;
color: #4f7092;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: #dbeafe;
color: #0b1c34;
}
.modal-content h2 {
color: #0b1c34;
margin-bottom: 20px;
padding-right: 30px;
}
.detail-section {
margin-bottom: 25px;
}
.detail-section h3 {
color: #1f3f60;
margin-bottom: 10px;
font-size: 18px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.detail-item {
padding: 10px;
background: #f9fcff;
border-radius: 15px;
}
.detail-item .label {
font-size: 12px;
color: #4f7092;
margin-bottom: 4px;
}
.detail-item .value {
font-weight: 600;
color: #0b1c34;
}
.experience-list, .education-list {
margin-top: 10px;
}
.exp-item, .edu-item {
background: #f9fcff;
padding: 15px;
border-radius: 15px;
margin-bottom: 10px;
}
.exp-item strong, .edu-item strong {
color: #0b1c34;
}
.exp-item .period, .edu-item .year {
color: #4f7092;
font-size: 12px;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 30px;
background: white;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
z-index: 9999;
animation: slideIn 0.3s;
max-width: 350px;
display: none;
}
.notification.success {
background: #10b981;
color: white;
}
.notification.error {
background: #ef4444;
color: white;
}
.notification.info {
background: #3b82f6;
color: white;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0%); opacity: 1; }
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 30px;
flex-wrap: wrap;
}
.page-btn {
padding: 8px 16px;
border: 2px solid #dee9f5;
background: white;
border-radius: 30px;
cursor: pointer;
transition: 0.2s;
}
.page-btn:hover {
background: #eef4fa;
}
.page-btn.active {
background: #0b1c34;
color: white;
border-color: #0b1c34;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
/* Стили для ссылок на профили */
.user-link {
display: flex;
align-items: center;
gap: 8px;
color: #0b1c34;
text-decoration: none;
font-weight: 600;
padding: 4px 8px;
border-radius: 30px;
transition: 0.2s;
}
.user-link:hover {
background: #eef4fa;
color: #0b1c34;
}
.user-link i {
color: #3b82f6;
font-size: 18px;
}
.user-link .external-icon {
font-size: 12px;
color: #4f7092;
opacity: 0.5;
transition: 0.2s;
}
.user-link:hover .external-icon {
opacity: 1;
color: #3b82f6;
}
/* Стили для кнопок действий */
.action-buttons {
display: flex;
gap: 5px;
justify-content: center;
}
.btn-icon {
width: 32px;
height: 32px;
border-radius: 16px;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
background: transparent;
color: #4f7092;
}
.btn-icon:hover {
background: #eef4fa;
transform: scale(1.1);
}
.btn-icon.view:hover {
background: #dbeafe;
color: #1e40af;
}
.btn-icon.delete:hover {
background: #fee2e2;
color: #b91c1c;
}
/* Email ссылка */
.email-link {
color: #3b82f6;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
}
.email-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo" onclick="window.location.href='/'">
<i class="fas fa-briefcase"></i>
Rabota.Today Admin
</div>
<div class="nav" id="nav">
<!-- Навигация -->
</div>
</div>
<!-- Статистика -->
<div class="stats-grid" id="stats">
<div class="stat-card" onclick="filterByType('users')">
<div class="label">Пользователи</div>
<div class="value" id="totalUsers">0</div>
</div>
<div class="stat-card" onclick="filterByType('employees')">
<div class="label">Соискатели</div>
<div class="value" id="totalEmployees">0</div>
</div>
<div class="stat-card" onclick="filterByType('employers')">
<div class="label">Работодатели</div>
<div class="value" id="totalEmployers">0</div>
</div>
<div class="stat-card" onclick="filterByType('vacancies')">
<div class="label">Вакансии</div>
<div class="value" id="activeVacancies">0</div>
</div>
<div class="stat-card" onclick="filterByType('resumes')">
<div class="label">Резюме</div>
<div class="value" id="totalResumes">0</div>
</div>
<div class="stat-card" onclick="filterByType('applications')">
<div class="label">Отклики</div>
<div class="value" id="totalApplications">0</div>
</div>
</div>
<!-- Табы -->
<div class="tabs">
<button class="tab active" onclick="switchTab('users')">Пользователи</button>
<button class="tab" onclick="switchTab('vacancies')">Вакансии</button>
<button class="tab" onclick="switchTab('resumes')">Резюме</button>
<button class="tab" onclick="switchTab('tags')">Теги</button>
</div>
<!-- Контент -->
<div class="content-card">
<!-- Пользователи -->
<div id="usersTab">
<div class="table-header">
<h2><i class="fas fa-users"></i> Управление пользователями</h2>
<div class="search-box">
<input type="text" id="userSearch" placeholder="Поиск по email или имени..." oninput="debounceSearch('users')">
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Роль</th>
<th>Дата регистрации</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="usersTable"></tbody>
</table>
</div>
<div class="pagination" id="usersPagination"></div>
</div>
<!-- Вакансии -->
<div id="vacanciesTab" style="display: none;">
<div class="table-header">
<h2><i class="fas fa-briefcase"></i> Управление вакансиями</h2>
<div class="search-box">
<input type="text" id="vacancySearch" placeholder="Поиск по названию..." oninput="debounceSearch('vacancies')">
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Компания</th>
<th>Зарплата</th>
<th>Теги</th>
<th>Статус</th>
<th>Просмотры</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="vacanciesTable"></tbody>
</table>
</div>
<div class="pagination" id="vacanciesPagination"></div>
</div>
<!-- Резюме -->
<div id="resumesTab" style="display: none;">
<div class="table-header">
<h2><i class="fas fa-file-alt"></i> Управление резюме</h2>
<div class="search-box">
<input type="text" id="resumeSearch" placeholder="Поиск по имени или должности..." oninput="debounceSearch('resumes')">
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Желаемая должность</th>
<th>Зарплата</th>
<th>Опыт</th>
<th>Теги</th>
<th>Просмотры</th>
<th>Обновлено</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="resumesTable"></tbody>
</table>
</div>
<div class="pagination" id="resumesPagination"></div>
</div>
<!-- Теги -->
<div id="tagsTab" style="display: none;">
<div class="table-header">
<h2><i class="fas fa-tags"></i> Управление тегами</h2>
<button class="btn-primary" onclick="showAddTagModal()">
<i class="fas fa-plus"></i> Добавить тег
</button>
</div>
<div class="tags-cloud" id="tagsCloud"></div>
<h3 style="margin: 30px 0 20px;">Популярные теги</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Тег</th>
<th>Категория</th>
<th>Вакансий</th>
<th>Резюме</th>
<th>Всего</th>
</tr>
</thead>
<tbody id="popularTagsTable"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Модальное окно добавления тега -->
<div class="modal" id="tagModal">
<div class="modal-content">
<button class="modal-close" onclick="closeTagModal()">&times;</button>
<h2>Новый тег</h2>
<div class="detail-section">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Название тега</label>
<input type="text" id="tagName" placeholder="Например: Python" style="width: 100%; padding: 12px; border: 2px solid #dee9f5; border-radius: 30px;">
</div>
<div class="detail-section">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Категория</label>
<select id="tagCategory" style="width: 100%; padding: 12px; border: 2px solid #dee9f5; border-radius: 30px;">
<option value="skill">Навык</option>
<option value="industry">Отрасль</option>
<option value="position">Должность</option>
<option value="other">Другое</option>
</select>
</div>
<div style="display: flex; gap: 10px; margin-top: 30px;">
<button class="btn-primary" onclick="createTag()" style="flex: 1; padding: 14px;">Создать</button>
<button class="btn-secondary" onclick="closeTagModal()" style="flex: 1; padding: 14px;">Отмена</button>
</div>
</div>
</div>
<!-- Модальное окно просмотра резюме -->
<div class="modal" id="resumeViewModal">
<div class="modal-content">
<button class="modal-close" onclick="closeResumeModal()">&times;</button>
<h2 id="resumeModalName"></h2>
<div id="resumeModalContent"></div>
</div>
</div>
<!-- Уведомления -->
<div class="notification" id="notification"></div>
<script>
const API_BASE_URL = window.location.protocol + '//' + window.location.host + '/api';
const token = localStorage.getItem('accessToken');
// Проверка авторизации
if (!token) {
window.location.href = '/login';
}
// Переменные для пагинации
let currentPage = {
users: 1,
vacancies: 1,
resumes: 1
};
let searchTerms = {
users: '',
vacancies: '',
resumes: ''
};
let searchTimeouts = {};
// ========== УВЕДОМЛЕНИЯ ==========
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
notification.className = `notification ${type}`;
notification.innerHTML = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// ========== ЗАГРУЗКА СТАТИСТИКИ ==========
async function loadStats() {
try {
const response = await fetch(`${API_BASE_URL}/admin/stats`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка загрузки статистики');
const stats = await response.json();
document.getElementById('totalUsers').textContent = stats.total_users || 0;
document.getElementById('totalEmployees').textContent = stats.total_employees || 0;
document.getElementById('totalEmployers').textContent = stats.total_employers || 0;
document.getElementById('activeVacancies').textContent = stats.active_vacancies || 0;
document.getElementById('totalResumes').textContent = stats.total_resumes || 0;
document.getElementById('totalApplications').textContent = stats.total_applications || 0;
// Загружаем популярные теги
const popularTagsTable = document.getElementById('popularTagsTable');
if (stats.popular_tags && stats.popular_tags.length > 0) {
popularTagsTable.innerHTML = stats.popular_tags.map(t => `
<tr>
<td><strong>${escapeHtml(t.name)}</strong></td>
<td>${t.category || '—'}</td>
<td>${t.vacancy_count || 0}</td>
<td>${t.resume_count || 0}</td>
<td>${(t.vacancy_count || 0) + (t.resume_count || 0)}</td>
</tr>
`).join('');
} else {
popularTagsTable.innerHTML = '<tr><td colspan="5" style="text-align: center;">Нет данных</td></tr>';
}
} catch (error) {
console.error('Error loading stats:', error);
showNotification('Ошибка загрузки статистики', 'error');
}
}
// ========== ЗАГРУЗКА ПОЛЬЗОВАТЕЛЕЙ ==========
async function loadUsers(page = 1, search = '') {
try {
console.log(`📥 Загрузка пользователей: страница ${page}, поиск "${search}"`);
console.log('🔑 Токен:', token ? 'Есть' : 'Нет');
let url = `${API_BASE_URL}/admin/users?page=${page}&limit=10`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error('❌ Ошибка ответа:', response.status, response.statusText);
throw new Error('Ошибка загрузки пользователей');
}
const data = await response.json();
console.log('✅ Данные пользователей:', data);
const users = data.users || data;
const totalPages = data.total_pages || Math.ceil((data.total || users.length) / 10) || 1;
const table = document.getElementById('usersTable');
if (!users || users.length === 0) {
table.innerHTML = '<tr><td colspan="7" style="text-align: center;">Пользователи не найдены</td></tr>';
return;
}
table.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>
<a href="/user/${u.id}" target="_blank" style="color: #0b1c34; text-decoration: none; font-weight: 600; display: flex; align-items: center; gap: 5px;">
<i class="fas fa-user-circle" style="color: #3b82f6;"></i>
${escapeHtml(u.full_name)}
<i class="fas fa-external-link-alt" style="font-size: 12px; color: #4f7092; opacity: 0.5;"></i>
</a>
</td>
<td>
<a href="mailto:${escapeHtml(u.email)}" style="color: #3b82f6; text-decoration: none;">
${escapeHtml(u.email)}
</a>
</td>
<td>${escapeHtml(u.phone || '—')}</td>
<td>
<span class="badge ${u.role}">
${u.role === 'employee' ? '👤 Соискатель' :
u.role === 'employer' ? '🏢 Работодатель' :
'👑 Админ'}
</span>
</td>
<td>${new Date(u.created_at).toLocaleDateString()}</td>
<td>
<div style="display: flex; gap: 5px;">
<button class="btn-icon view" onclick="window.open('/user/${u.id}', '_blank')" title="Просмотр профиля">
<i class="fas fa-eye"></i>
</button>
${!u.is_admin ? `
<button class="btn-icon delete" onclick="deleteUser(${u.id})" title="Удалить">
<i class="fas fa-trash"></i>
</button>
` : ''}
</div>
</td>
</tr>
`).join('');
renderPagination('users', totalPages, page);
} catch (error) {
console.error('❌ Ошибка загрузки пользователей:', error);
document.getElementById('usersTable').innerHTML = '<tr><td colspan="7" style="text-align: center;">Ошибка загрузки</td></tr>';
}
}
// ========== ЗАГРУЗКА ВАКАНСИЙ ==========
async function loadVacancies(page = 1, search = '') {
try {
let url = `${API_BASE_URL}/admin/vacancies?page=${page}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка загрузки вакансий');
const data = await response.json();
const vacancies = data.vacancies || data;
const table = document.getElementById('vacanciesTable');
if (!vacancies || vacancies.length === 0) {
table.innerHTML = '<tr><td colspan="9" style="text-align: center;">Вакансии не найдены</td></tr>';
return;
}
table.innerHTML = vacancies.map(v => `
<tr>
<td>${v.id}</td>
<td><strong>${escapeHtml(v.title)}</strong></td>
<td>${escapeHtml(v.company_name || '—')}</td>
<td>${escapeHtml(v.salary || '—')}</td>
<td>
${(v.tags || []).map(t =>
`<span class="badge" style="background: #eef4fa; color: #1f3f60; margin: 2px;">${escapeHtml(t.name)}</span>`
).join('')}
</td>
<td>
<span class="badge ${v.is_active ? 'active' : 'inactive'}">
${v.is_active ? 'Активна' : 'Неактивна'}
</span>
</td>
<td>${v.views || 0}</td>
<td>${new Date(v.created_at).toLocaleDateString()}</td>
<td>
<button class="btn-icon view" onclick="viewVacancy(${v.id})" title="Просмотр">
<i class="fas fa-eye"></i>
</button>
<button class="btn-icon delete" onclick="deleteVacancy(${v.id})" title="Удалить">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
renderPagination('vacancies', data.total_pages || 1, page);
} catch (error) {
console.error('Error loading vacancies:', error);
document.getElementById('vacanciesTable').innerHTML = '<tr><td colspan="9" style="text-align: center;">Ошибка загрузки</td></tr>';
}
}
// ========== ЗАГРУЗКА РЕЗЮМЕ ==========
async function loadResumes(page = 1, search = '') {
try {
console.log(`📥 Загрузка резюме: страница ${page}, поиск "${search}"`);
let url = `${API_BASE_URL}/admin/resumes?page=${page}&limit=10`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
console.warn('Эндпоинт admin/resumes не найден, пробуем резервный вариант');
// Пробуем загрузить через публичный эндпоинт
await loadResumesFallback(page, search);
return;
}
const data = await response.json();
console.log('✅ Резюме загружены:', data);
const resumes = data.resumes || data;
const totalPages = data.total_pages || Math.ceil((data.total || resumes.length) / 10) || 1;
const table = document.getElementById('resumesTable');
if (!resumes || resumes.length === 0) {
table.innerHTML = '<tr><td colspan="9" style="text-align: center;">Резюме не найдены</td></tr>';
return;
}
table.innerHTML = resumes.map(r => {
// Безопасное получение опыта
const expCount = r.work_experience ? r.work_experience.length : (r.experience_count || 0);
return `
<tr>
<td>${r.id}</td>
<td><strong>${escapeHtml(r.full_name || '—')}</strong></td>
<td>${escapeHtml(r.desired_position || '—')}</td>
<td>${escapeHtml(r.desired_salary || '—')}</td>
<td>${expCount} ${getDeclension(expCount, ['место', 'места', 'мест'])}</td>
<td>
${(r.tags || []).map(t =>
`<span class="badge" style="background: #eef4fa; color: #1f3f60; margin: 2px;">${escapeHtml(t.name || t)}</span>`
).join('')}
</td>
<td>${r.views || 0}</td>
<td>${r.updated_at ? new Date(r.updated_at).toLocaleDateString() : '—'}</td>
<td>
<button class="btn-icon view" onclick="viewResume(${r.id})" title="Просмотр">
<i class="fas fa-eye"></i>
</button>
<button class="btn-icon delete" onclick="deleteResume(${r.id})" title="Удалить">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`}).join('');
renderPagination('resumes', totalPages, page);
} catch (error) {
console.error('❌ Ошибка загрузки резюме:', error);
document.getElementById('resumesTable').innerHTML = '<tr><td colspan="9" style="text-align: center;">Ошибка загрузки резюме</td></tr>';
}
}
// Резервный метод загрузки резюме
async function loadResumesFallback(page = 1, search = '') {
try {
console.log('🔄 Используем резервный метод загрузки резюме');
let url = `${API_BASE_URL}/resumes/all?page=${page}&limit=10`;
if (search) url += `&search=${encodeURIComponent(search)}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Ошибка загрузки резюме');
const data = await response.json();
console.log('✅ Резюме загружены (резервный метод):', data);
const resumes = data.resumes || [];
const totalPages = data.total_pages || 1;
const table = document.getElementById('resumesTable');
if (!resumes || resumes.length === 0) {
table.innerHTML = '<tr><td colspan="9" style="text-align: center;">Резюме не найдены</td></tr>';
return;
}
table.innerHTML = resumes.map(r => {
const expCount = r.experience_count || 0;
return `
<tr>
<td>${r.id}</td>
<td><strong>${escapeHtml(r.full_name || '—')}</strong></td>
<td>${escapeHtml(r.desired_position || '—')}</td>
<td>${escapeHtml(r.desired_salary || '—')}</td>
<td>${expCount} ${getDeclension(expCount, ['место', 'места', 'мест'])}</td>
<td>
${(r.tags || []).map(t =>
`<span class="badge" style="background: #eef4fa; color: #1f3f60; margin: 2px;">${escapeHtml(t.name || t)}</span>`
).join('')}
</td>
<td>${r.views || 0}</td>
<td>${r.updated_at ? new Date(r.updated_at).toLocaleDateString() : '—'}</td>
<td>
<button class="btn-icon view" onclick="viewResume(${r.id})" title="Просмотр">
<i class="fas fa-eye"></i>
</button>
<button class="btn-icon delete" onclick="deleteResume(${r.id})" title="Удалить">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`}).join('');
renderPagination('resumes', totalPages, page);
} catch (error) {
console.error('❌ Ошибка резервной загрузки резюме:', error);
throw error;
}
}
// Вспомогательная функция для склонения
function getDeclension(number, titles) {
const cases = [2, 0, 1, 1, 1, 2];
return titles[ (number % 100 > 4 && number % 100 < 20) ? 2 : cases[(number % 10 < 5) ? number % 10 : 5] ];
}
// ========== ПРОСМОТР РЕЗЮМЕ ==========
async function viewResume(resumeId) {
try {
const response = await fetch(`${API_BASE_URL}/resumes/${resumeId}`);
if (!response.ok) throw new Error('Ошибка загрузки резюме');
const resume = await response.json();
document.getElementById('resumeModalName').textContent = escapeHtml(resume.full_name || 'Резюме');
const experienceHtml = resume.work_experience && resume.work_experience.length > 0
? resume.work_experience.map(exp => `
<div class="exp-item">
<strong>${escapeHtml(exp.position)}</strong> <span class="period">${escapeHtml(exp.period || '')}</span><br>
<span>${escapeHtml(exp.company)}</span>
${exp.description ? `<p style="margin-top: 8px; font-size: 13px;">${escapeHtml(exp.description.substring(0, 100))}...</p>` : ''}
</div>
`).join('')
: '<p>Опыт работы не указан</p>';
const educationHtml = resume.education && resume.education.length > 0
? resume.education.map(edu => `
<div class="edu-item">
<strong>${escapeHtml(edu.institution)}</strong> <span class="year">${escapeHtml(edu.graduation_year || '')}</span><br>
<span>${escapeHtml(edu.specialty || '')}</span>
</div>
`).join('')
: '<p>Образование не указано</p>';
document.getElementById('resumeModalContent').innerHTML = `
<div class="detail-section">
<h3>Основная информация</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="label">Желаемая должность</div>
<div class="value">${escapeHtml(resume.desired_position || '—')}</div>
</div>
<div class="detail-item">
<div class="label">Зарплата</div>
<div class="value">${escapeHtml(resume.desired_salary || '—')}</div>
</div>
<div class="detail-item">
<div class="label">Просмотров</div>
<div class="value">${resume.views || 0}</div>
</div>
<div class="detail-item">
<div class="label">Обновлено</div>
<div class="value">${resume.updated_at ? new Date(resume.updated_at).toLocaleDateString() : '—'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>О себе</h3>
<p>${escapeHtml(resume.about_me || 'Информация не заполнена')}</p>
</div>
<div class="detail-section">
<h3>Опыт работы</h3>
<div class="experience-list">
${experienceHtml}
</div>
</div>
<div class="detail-section">
<h3>Образование</h3>
<div class="education-list">
${educationHtml}
</div>
</div>
<div class="detail-section">
<h3>Контакты</h3>
<div class="detail-grid">
<div class="detail-item">
<div class="label">Email</div>
<div class="value">${escapeHtml(resume.email || '—')}</div>
</div>
<div class="detail-item">
<div class="label">Телефон</div>
<div class="value">${escapeHtml(resume.phone || '—')}</div>
</div>
<div class="detail-item">
<div class="label">Telegram</div>
<div class="value">${escapeHtml(resume.telegram || '—')}</div>
</div>
</div>
</div>
`;
document.getElementById('resumeViewModal').classList.add('active');
} catch (error) {
console.error('Error loading resume details:', error);
showNotification('Ошибка загрузки деталей резюме', 'error');
}
}
function closeResumeModal() {
document.getElementById('resumeViewModal').classList.remove('active');
}
// ========== УДАЛЕНИЕ РЕЗЮМЕ ==========
async function deleteResume(resumeId) {
if (!confirm('Вы уверены, что хотите удалить это резюме? Это действие нельзя отменить.')) return;
try {
const response = await fetch(`${API_BASE_URL}/admin/resumes/${resumeId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка удаления');
showNotification('Резюме удалено', 'success');
loadResumes(currentPage.resumes, searchTerms.resumes);
loadStats(); // Обновляем статистику
} catch (error) {
console.error('Error deleting resume:', error);
showNotification('Ошибка при удалении резюме', 'error');
}
}
// ========== УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ ==========
async function deleteUser(userId) {
if (!confirm('Удалить пользователя? Это действие нельзя отменить.')) return;
try {
const response = await fetch(`${API_BASE_URL}/admin/users/${userId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка удаления');
showNotification('Пользователь удален', 'success');
loadUsers(currentPage.users, searchTerms.users);
loadStats();
} catch (error) {
console.error('Error deleting user:', error);
showNotification('Ошибка при удалении', 'error');
}
}
// ========== УДАЛЕНИЕ ВАКАНСИИ ==========
async function deleteVacancy(vacancyId) {
if (!confirm('Удалить вакансию?')) return;
try {
const response = await fetch(`${API_BASE_URL}/admin/vacancies/${vacancyId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка удаления');
showNotification('Вакансия удалена', 'success');
loadVacancies(currentPage.vacancies, searchTerms.vacancies);
loadStats();
} catch (error) {
console.error('Error deleting vacancy:', error);
showNotification('Ошибка при удалении', 'error');
}
}
// ========== ПРОСМОТР ВАКАНСИИ ==========
function viewVacancy(vacancyId) {
window.open(`/vacancy/${vacancyId}`, '_blank');
}
// ========== ПАГИНАЦИЯ ==========
function renderPagination(type, totalPages, currentPage) {
const container = document.getElementById(`${type}Pagination`);
if (!container) return;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="goToPage('${type}', ${i})">${i}</button>`;
}
container.innerHTML = html;
}
function goToPage(type, page) {
currentPage[type] = page;
switch(type) {
case 'users':
loadUsers(page, searchTerms.users);
break;
case 'vacancies':
loadVacancies(page, searchTerms.vacancies);
break;
case 'resumes':
loadResumes(page, searchTerms.resumes);
break;
}
}
// ========== ПОИСК С DEBOUNCE ==========
function debounceSearch(type) {
clearTimeout(searchTimeouts[type]);
searchTimeouts[type] = setTimeout(() => {
const searchInput = document.getElementById(`${type}Search`);
if (searchInput) {
searchTerms[type] = searchInput.value;
currentPage[type] = 1;
switch(type) {
case 'users':
loadUsers(1, searchTerms[type]);
break;
case 'vacancies':
loadVacancies(1, searchTerms[type]);
break;
case 'resumes':
loadResumes(1, searchTerms[type]);
break;
}
}
}, 500);
}
// ========== ПЕРЕКЛЮЧЕНИЕ ТАБОВ ==========
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.content-card > div').forEach(d => d.style.display = 'none');
const activeTab = document.querySelector(`.tab:nth-child(${tab === 'users' ? 1 : tab === 'vacancies' ? 2 : tab === 'resumes' ? 3 : 4})`);
if (activeTab) activeTab.classList.add('active');
if (tab === 'users') {
document.getElementById('usersTab').style.display = 'block';
loadUsers(currentPage.users, searchTerms.users);
} else if (tab === 'vacancies') {
document.getElementById('vacanciesTab').style.display = 'block';
loadVacancies(currentPage.vacancies, searchTerms.vacancies);
} else if (tab === 'resumes') {
document.getElementById('resumesTab').style.display = 'block';
loadResumes(currentPage.resumes, searchTerms.resumes);
} else if (tab === 'tags') {
document.getElementById('tagsTab').style.display = 'block';
}
}
// ========== ФИЛЬТРАЦИЯ ПО СТАТИСТИКЕ ==========
function filterByType(type) {
if (type === 'users') {
switchTab('users');
} else if (type === 'employees') {
switchTab('users');
// Здесь можно добавить фильтр по роли
} else if (type === 'employers') {
switchTab('users');
} else if (type === 'vacancies') {
switchTab('vacancies');
} else if (type === 'resumes') {
switchTab('resumes');
} else if (type === 'applications') {
window.location.href = '/applications';
}
}
// ========== РАБОТА С ТЕГАМИ ==========
function showAddTagModal() {
document.getElementById('tagModal').classList.add('active');
}
function closeTagModal() {
document.getElementById('tagModal').classList.remove('active');
document.getElementById('tagName').value = '';
}
async function createTag() {
const name = document.getElementById('tagName').value.trim();
const category = document.getElementById('tagCategory').value;
if (!name) {
showNotification('Введите название тега', 'error');
return;
}
try {
const response = await fetch(`${API_BASE_URL}/tags`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name, category })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Ошибка создания тега');
}
showNotification('Тег создан', 'success');
closeTagModal();
loadStats(); // Обновляем статистику
} catch (error) {
console.error('Error creating tag:', error);
showNotification(error.message, 'error');
}
}
// ========== ЭКРАНИРОВАНИЕ HTML ==========
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// ========== НАВИГАЦИЯ ==========
function updateNavigation() {
const nav = document.getElementById('nav');
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile">Профиль</a>
<a href="#" onclick="logout()">Выйти</a>
`;
}
function logout() {
localStorage.removeItem('accessToken');
window.location.href = '/';
}
// ========== ЗАКРЫТИЕ МОДАЛОК ПО КЛИКУ ВНЕ ==========
window.onclick = function(event) {
const tagModal = document.getElementById('tagModal');
const resumeModal = document.getElementById('resumeViewModal');
if (event.target === tagModal) {
tagModal.classList.remove('active');
}
if (event.target === resumeModal) {
resumeModal.classList.remove('active');
}
};
// ========== ИНИЦИАЛИЗАЦИЯ ==========
window.addEventListener('load', () => {
updateNavigation();
loadStats();
loadUsers();
// Не загружаем все сразу, только активный таб
});
</script>
</body>
</html>