admin company page
This commit is contained in:
119
server.py
119
server.py
@@ -2161,6 +2161,125 @@ async def create_or_update_company(
|
||||
return dict(cursor.fetchone())
|
||||
|
||||
|
||||
@app.get("/api/admin/companies")
|
||||
async def get_all_companies_admin(
|
||||
user_id: int = Depends(get_current_user),
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
search: str = None
|
||||
):
|
||||
"""Получение всех компаний для админки"""
|
||||
try:
|
||||
print(f"👑 Админ {user_id} запрашивает список компаний")
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Проверка прав администратора
|
||||
cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
if not user or not user["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещен")
|
||||
|
||||
# Базовый запрос
|
||||
query = """
|
||||
SELECT
|
||||
c.*,
|
||||
u.full_name as owner_name,
|
||||
(SELECT COUNT(*) FROM vacancies WHERE user_id = c.user_id AND is_active = 1) as vacancies_count
|
||||
FROM companies c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
# Поиск
|
||||
if search:
|
||||
query += " AND (c.name LIKE ? OR c.email LIKE ? OR u.full_name LIKE ?)"
|
||||
search_term = f"%{search}%"
|
||||
params.extend([search_term, search_term, search_term])
|
||||
|
||||
# Сортировка
|
||||
query += " ORDER BY c.created_at DESC"
|
||||
|
||||
# Пагинация
|
||||
offset = (page - 1) * limit
|
||||
query += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
cursor.execute(query, params)
|
||||
companies = cursor.fetchall()
|
||||
|
||||
# Получаем общее количество
|
||||
count_query = "SELECT COUNT(*) FROM companies"
|
||||
if search:
|
||||
count_query += " WHERE name LIKE ? OR email LIKE ?"
|
||||
cursor.execute(count_query, [search_term, search_term])
|
||||
else:
|
||||
cursor.execute(count_query)
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
result = [dict(c) for c in companies]
|
||||
|
||||
return {
|
||||
"companies": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"total_pages": (total + limit - 1) // limit,
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при загрузке компаний: {e}")
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}")
|
||||
|
||||
|
||||
@app.delete("/api/admin/companies/{company_id}")
|
||||
async def delete_company_admin(
|
||||
company_id: int,
|
||||
user_id: int = Depends(get_current_user)
|
||||
):
|
||||
"""Удаление компании (только для админа)"""
|
||||
try:
|
||||
print(f"👑 Админ {user_id} пытается удалить компанию {company_id}")
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Проверка прав администратора
|
||||
cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,))
|
||||
user = cursor.fetchone()
|
||||
if not user or not user["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Доступ запрещен")
|
||||
|
||||
# Проверяем существование компании
|
||||
cursor.execute("SELECT user_id FROM companies WHERE id = ?", (company_id,))
|
||||
company = cursor.fetchone()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="Компания не найдена")
|
||||
|
||||
# Удаляем вакансии компании
|
||||
cursor.execute("DELETE FROM vacancies WHERE user_id = ?", (company["user_id"],))
|
||||
|
||||
# Удаляем компанию
|
||||
cursor.execute("DELETE FROM companies WHERE id = ?", (company_id,))
|
||||
|
||||
conn.commit()
|
||||
print(f"✅ Компания {company_id} успешно удалена")
|
||||
|
||||
return {"message": "Компания успешно удалена"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при удалении компании {company_id}: {e}")
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/companies/{company_id}")
|
||||
async def get_company_by_id(company_id: int):
|
||||
"""Получение информации о компании по ID (публичный эндпоинт)"""
|
||||
|
||||
@@ -284,29 +284,23 @@
|
||||
background: #bae6fd;
|
||||
}
|
||||
|
||||
.tags-cloud {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
.company-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #eef4fa;
|
||||
padding: 8px 16px;
|
||||
border-radius: 30px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #3b82f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-item .count {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
.company-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -402,26 +396,6 @@
|
||||
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;
|
||||
@@ -488,90 +462,6 @@
|
||||
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>
|
||||
@@ -619,6 +509,7 @@
|
||||
<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('companies')">Компании</button>
|
||||
<button class="tab" onclick="switchTab('tags')">Теги</button>
|
||||
</div>
|
||||
|
||||
@@ -709,11 +600,41 @@
|
||||
<div class="pagination" id="resumesPagination"></div>
|
||||
</div>
|
||||
|
||||
<!-- Компании -->
|
||||
<div id="companiesTab" style="display: none;">
|
||||
<div class="table-header">
|
||||
<h2><i class="fas fa-building"></i> Управление компаниями</h2>
|
||||
<div class="search-box">
|
||||
<input type="text" id="companySearch" placeholder="Поиск по названию или email..." oninput="debounceSearch('companies')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Логотип</th>
|
||||
<th>Название</th>
|
||||
<th>Владелец</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Сайт</th>
|
||||
<th>Вакансий</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="companiesTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="companiesPagination"></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()">
|
||||
<button class="btn-primary" onclick="showAddTagModal()" style="padding: 12px 24px; background: #0b1c34; color: white; border: none; border-radius: 40px; cursor: pointer;">
|
||||
<i class="fas fa-plus"></i> Добавить тег
|
||||
</button>
|
||||
</div>
|
||||
@@ -761,19 +682,18 @@
|
||||
</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>
|
||||
<button class="btn-primary" onclick="createTag()" style="flex: 1; padding: 14px; background: #0b1c34; color: white; border: none; border-radius: 40px; cursor: pointer;">Создать</button>
|
||||
<button class="btn-secondary" onclick="closeTagModal()" style="flex: 1; padding: 14px; background: #eef4fa; border: none; border-radius: 40px; cursor: pointer;">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно просмотра резюме -->
|
||||
<div class="modal" id="resumeViewModal">
|
||||
<!-- Модальное окно просмотра компании -->
|
||||
<div class="modal" id="companyViewModal">
|
||||
<div class="modal-content">
|
||||
<button class="modal-close" onclick="closeResumeModal()">×</button>
|
||||
<h2 id="resumeModalName"></h2>
|
||||
|
||||
<div id="resumeModalContent"></div>
|
||||
<button class="modal-close" onclick="closeCompanyModal()">×</button>
|
||||
<h2 id="companyModalTitle">Информация о компании</h2>
|
||||
<div id="companyModalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -793,13 +713,15 @@
|
||||
let currentPage = {
|
||||
users: 1,
|
||||
vacancies: 1,
|
||||
resumes: 1
|
||||
resumes: 1,
|
||||
companies: 1
|
||||
};
|
||||
|
||||
let searchTerms = {
|
||||
users: '',
|
||||
vacancies: '',
|
||||
resumes: ''
|
||||
resumes: '',
|
||||
companies: ''
|
||||
};
|
||||
|
||||
let searchTimeouts = {};
|
||||
@@ -807,6 +729,7 @@
|
||||
// ========== УВЕДОМЛЕНИЯ ==========
|
||||
function showNotification(message, type = 'success') {
|
||||
const notification = document.getElementById('notification');
|
||||
if (!notification) return;
|
||||
notification.className = `notification ${type}`;
|
||||
notification.innerHTML = message;
|
||||
notification.style.display = 'block';
|
||||
@@ -860,26 +783,17 @@
|
||||
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'
|
||||
}
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Ошибка ответа:', response.status, response.statusText);
|
||||
throw new Error('Ошибка загрузки пользователей');
|
||||
}
|
||||
if (!response.ok) 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;
|
||||
|
||||
@@ -940,7 +854,9 @@
|
||||
// ========== ЗАГРУЗКА ВАКАНСИЙ ==========
|
||||
async function loadVacancies(page = 1, search = '') {
|
||||
try {
|
||||
let url = `${API_BASE_URL}/admin/vacancies?page=${page}`;
|
||||
console.log(`📥 Загрузка вакансий: страница ${page}, поиск "${search}"`);
|
||||
|
||||
let url = `${API_BASE_URL}/admin/vacancies?page=${page}&limit=10`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -951,6 +867,7 @@
|
||||
|
||||
const data = await response.json();
|
||||
const vacancies = data.vacancies || data;
|
||||
const totalPages = data.total_pages || Math.ceil((data.total || vacancies.length) / 10) || 1;
|
||||
|
||||
const table = document.getElementById('vacanciesTable');
|
||||
|
||||
@@ -978,20 +895,22 @@
|
||||
<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>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<button class="btn-icon view" onclick="window.open('/vacancy/${v.id}', '_blank')" 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
renderPagination('vacancies', data.total_pages || 1, page);
|
||||
renderPagination('vacancies', totalPages, page);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading vacancies:', error);
|
||||
console.error('❌ Ошибка загрузки вакансий:', error);
|
||||
document.getElementById('vacanciesTable').innerHTML = '<tr><td colspan="9" style="text-align: center;">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
@@ -1008,16 +927,9 @@
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Эндпоинт admin/resumes не найден, пробуем резервный вариант');
|
||||
// Пробуем загрузить через публичный эндпоинт
|
||||
await loadResumesFallback(page, search);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error('Ошибка загрузки резюме');
|
||||
|
||||
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;
|
||||
|
||||
@@ -1029,9 +941,7 @@
|
||||
}
|
||||
|
||||
table.innerHTML = resumes.map(r => {
|
||||
// Безопасное получение опыта
|
||||
const expCount = r.work_experience ? r.work_experience.length : (r.experience_count || 0);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
@@ -1047,12 +957,14 @@
|
||||
<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>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join('');
|
||||
@@ -1061,77 +973,83 @@
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка загрузки резюме:', error);
|
||||
document.getElementById('resumesTable').innerHTML = '<tr><td colspan="9" style="text-align: center;">Ошибка загрузки резюме</td></tr>';
|
||||
document.getElementById('resumesTable').innerHTML = '<tr><td colspan="9" style="text-align: center;">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Резервный метод загрузки резюме
|
||||
async function loadResumesFallback(page = 1, search = '') {
|
||||
// ========== ЗАГРУЗКА КОМПАНИЙ ==========
|
||||
async function loadCompanies(page = 1, search = '') {
|
||||
try {
|
||||
console.log('🔄 Используем резервный метод загрузки резюме');
|
||||
console.log(`📥 Загрузка компаний: страница ${page}, поиск "${search}"`);
|
||||
|
||||
let url = `${API_BASE_URL}/resumes/all?page=${page}&limit=10`;
|
||||
let url = `${API_BASE_URL}/admin/companies?page=${page}&limit=10`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка загрузки резюме');
|
||||
if (!response.ok) throw new Error('Ошибка загрузки компаний');
|
||||
|
||||
const data = await response.json();
|
||||
console.log('✅ Резюме загружены (резервный метод):', data);
|
||||
const companies = data.companies || data;
|
||||
const totalPages = data.total_pages || Math.ceil((data.total || companies.length) / 10) || 1;
|
||||
|
||||
const resumes = data.resumes || [];
|
||||
const totalPages = data.total_pages || 1;
|
||||
const table = document.getElementById('companiesTable');
|
||||
|
||||
const table = document.getElementById('resumesTable');
|
||||
|
||||
if (!resumes || resumes.length === 0) {
|
||||
table.innerHTML = '<tr><td colspan="9" style="text-align: center;">Резюме не найдены</td></tr>';
|
||||
if (!companies || companies.length === 0) {
|
||||
table.innerHTML = '<tr><td colspan="10" style="text-align: center;">Компании не найдены</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
table.innerHTML = resumes.map(r => {
|
||||
const expCount = r.experience_count || 0;
|
||||
|
||||
return `
|
||||
table.innerHTML = companies.map(c => `
|
||||
<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>${c.id}</td>
|
||||
<td>
|
||||
${(r.tags || []).map(t =>
|
||||
`<span class="badge" style="background: #eef4fa; color: #1f3f60; margin: 2px;">${escapeHtml(t.name || t)}</span>`
|
||||
).join('')}
|
||||
<div class="company-logo">
|
||||
${c.logo ?
|
||||
`<img src="${escapeHtml(c.logo)}" alt="${escapeHtml(c.name)}" style="width: 100%; height: 100%; object-fit: cover;">` :
|
||||
`<i class="fas fa-building"></i>`
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
<a href="/company/${c.id}" target="_blank" style="color: #0b1c34; text-decoration: none; font-weight: 600;">
|
||||
${escapeHtml(c.name)}
|
||||
<i class="fas fa-external-link-alt" style="font-size: 12px; color: #4f7092;"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/${c.user_id}" target="_blank" style="color: #3b82f6; text-decoration: none;">
|
||||
${escapeHtml(c.owner_name || '—')}
|
||||
</a>
|
||||
</td>
|
||||
<td>${escapeHtml(c.email || '—')}</td>
|
||||
<td>${escapeHtml(c.phone || '—')}</td>
|
||||
<td>${c.website ? `<a href="${escapeHtml(c.website)}" target="_blank" style="color: #3b82f6;">${escapeHtml(c.website)}</a>` : '—'}</td>
|
||||
<td>${c.vacancies_count || 0}</td>
|
||||
<td>${new Date(c.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<button class="btn-icon view" onclick="viewCompany(${c.id})" title="Просмотр">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn-icon delete" onclick="deleteCompany(${c.id})" title="Удалить">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join('');
|
||||
`).join('');
|
||||
|
||||
renderPagination('resumes', totalPages, page);
|
||||
renderPagination('companies', totalPages, page);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка резервной загрузки резюме:', error);
|
||||
throw error;
|
||||
console.error('❌ Ошибка загрузки компаний:', error);
|
||||
document.getElementById('companiesTable').innerHTML = '<tr><td colspan="10" style="text-align: center;">Ошибка загрузки</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательная функция для склонения
|
||||
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 {
|
||||
@@ -1235,29 +1153,104 @@
|
||||
document.getElementById('resumeViewModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// ========== УДАЛЕНИЕ РЕЗЮМЕ ==========
|
||||
async function deleteResume(resumeId) {
|
||||
if (!confirm('Вы уверены, что хотите удалить это резюме? Это действие нельзя отменить.')) return;
|
||||
|
||||
// ========== ПРОСМОТР КОМПАНИИ ==========
|
||||
async function viewCompany(companyId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/resumes/${resumeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const response = await fetch(`${API_BASE_URL}/companies/${companyId}`);
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка удаления');
|
||||
if (!response.ok) throw new Error('Ошибка загрузки компании');
|
||||
|
||||
showNotification('Резюме удалено', 'success');
|
||||
loadResumes(currentPage.resumes, searchTerms.resumes);
|
||||
loadStats(); // Обновляем статистику
|
||||
const company = await response.json();
|
||||
|
||||
document.getElementById('companyModalTitle').textContent = company.name;
|
||||
|
||||
const vacanciesResponse = await fetch(`${API_BASE_URL}/vacancies/all?company_id=${companyId}&limit=5`);
|
||||
const vacanciesData = vacanciesResponse.ok ? await vacanciesResponse.json() : { vacancies: [] };
|
||||
|
||||
document.getElementById('companyModalContent').innerHTML = `
|
||||
<div class="detail-section">
|
||||
<h3>Основная информация</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<div class="label">Название</div>
|
||||
<div class="value">${escapeHtml(company.name)}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Владелец</div>
|
||||
<div class="value">
|
||||
<a href="/user/${company.user_id}" target="_blank">${escapeHtml(company.owner_name || 'Пользователь')}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Дата регистрации</div>
|
||||
<div class="value">${new Date(company.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Активных вакансий</div>
|
||||
<div class="value">${vacanciesData.vacancies?.filter(v => v.is_active).length || 0}</div>
|
||||
</div>
|
||||
</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(company.email || '—')}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Телефон</div>
|
||||
<div class="value">${escapeHtml(company.phone || '—')}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Адрес</div>
|
||||
<div class="value">${escapeHtml(company.address || '—')}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="label">Сайт</div>
|
||||
<div class="value">${company.website ? `<a href="${escapeHtml(company.website)}" target="_blank">${escapeHtml(company.website)}</a>` : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${company.description ? `
|
||||
<div class="detail-section">
|
||||
<h3>Описание компании</h3>
|
||||
<div style="background: #f9fcff; padding: 15px; border-radius: 15px; line-height: 1.6;">
|
||||
${escapeHtml(company.description).replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>Последние вакансии (${vacanciesData.vacancies?.length || 0})</h3>
|
||||
${vacanciesData.vacancies && vacanciesData.vacancies.length > 0 ?
|
||||
vacanciesData.vacancies.slice(0, 5).map(v => `
|
||||
<div style="padding: 10px; background: #f9fcff; border-radius: 10px; margin-bottom: 10px;">
|
||||
<a href="/vacancy/${v.id}" target="_blank" style="color: #0b1c34; font-weight: 600;">${escapeHtml(v.title)}</a>
|
||||
<span style="color: #4f7092; font-size: 12px; margin-left: 10px;">${escapeHtml(v.salary || 'з/п не указана')}</span>
|
||||
<span class="badge ${v.is_active ? 'active' : 'inactive'}" style="margin-left: 10px;">${v.is_active ? 'Активна' : 'Неактивна'}</span>
|
||||
</div>
|
||||
`).join('') :
|
||||
'<p style="color: #4f7092;">Нет вакансий</p>'
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('companyViewModal').classList.add('active');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting resume:', error);
|
||||
showNotification('Ошибка при удалении резюме', 'error');
|
||||
console.error('Error loading company details:', error);
|
||||
showNotification('Ошибка загрузки деталей компании', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== УДАЛЕНИЕ ПОЛЬЗОВАТЕЛЯ ==========
|
||||
function closeCompanyModal() {
|
||||
document.getElementById('companyViewModal').classList.remove('active');
|
||||
}
|
||||
|
||||
// ========== УДАЛЕНИЕ ==========
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Удалить пользователя? Это действие нельзя отменить.')) return;
|
||||
|
||||
@@ -1279,7 +1272,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== УДАЛЕНИЕ ВАКАНСИИ ==========
|
||||
async function deleteVacancy(vacancyId) {
|
||||
if (!confirm('Удалить вакансию?')) return;
|
||||
|
||||
@@ -1301,13 +1293,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ПРОСМОТР ВАКАНСИИ ==========
|
||||
function viewVacancy(vacancyId) {
|
||||
window.open(`/vacancy/${vacancyId}`, '_blank');
|
||||
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 deleteCompany(companyId) {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту компанию? Это действие нельзя отменить. Все вакансии компании также будут удалены.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/companies/${companyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка удаления');
|
||||
|
||||
showNotification('Компания удалена', 'success');
|
||||
loadCompanies(currentPage.companies, searchTerms.companies);
|
||||
loadStats();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting company:', error);
|
||||
showNotification('Ошибка при удалении компании', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ПАГИНАЦИЯ ==========
|
||||
function renderPagination(type, totalPages, currentPage) {
|
||||
function renderPagination(type, totalPages, currentPageNum) {
|
||||
const container = document.getElementById(`${type}Pagination`);
|
||||
if (!container) return;
|
||||
|
||||
@@ -1318,7 +1347,7 @@
|
||||
|
||||
let html = '';
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="goToPage('${type}', ${i})">${i}</button>`;
|
||||
html += `<button class="page-btn ${i === currentPageNum ? 'active' : ''}" onclick="goToPage('${type}', ${i})">${i}</button>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
@@ -1337,6 +1366,9 @@
|
||||
case 'resumes':
|
||||
loadResumes(page, searchTerms.resumes);
|
||||
break;
|
||||
case 'companies':
|
||||
loadCompanies(page, searchTerms.companies);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,6 +1392,9 @@
|
||||
case 'resumes':
|
||||
loadResumes(1, searchTerms[type]);
|
||||
break;
|
||||
case 'companies':
|
||||
loadCompanies(1, searchTerms[type]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
@@ -1370,8 +1405,11 @@
|
||||
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');
|
||||
const tabMap = { 'users': 0, 'vacancies': 1, 'resumes': 2, 'companies': 3, 'tags': 4 };
|
||||
const index = tabMap[tab];
|
||||
if (index !== undefined) {
|
||||
document.querySelectorAll('.tab')[index]?.classList.add('active');
|
||||
}
|
||||
|
||||
if (tab === 'users') {
|
||||
document.getElementById('usersTab').style.display = 'block';
|
||||
@@ -1382,19 +1420,18 @@
|
||||
} else if (tab === 'resumes') {
|
||||
document.getElementById('resumesTab').style.display = 'block';
|
||||
loadResumes(currentPage.resumes, searchTerms.resumes);
|
||||
} else if (tab === 'companies') {
|
||||
document.getElementById('companiesTab').style.display = 'block';
|
||||
loadCompanies(currentPage.companies, searchTerms.companies);
|
||||
} else if (tab === 'tags') {
|
||||
document.getElementById('tagsTab').style.display = 'block';
|
||||
loadPopularTags();
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ФИЛЬТРАЦИЯ ПО СТАТИСТИКЕ ==========
|
||||
function filterByType(type) {
|
||||
if (type === 'users') {
|
||||
switchTab('users');
|
||||
} else if (type === 'employees') {
|
||||
switchTab('users');
|
||||
// Здесь можно добавить фильтр по роли
|
||||
} else if (type === 'employers') {
|
||||
if (type === 'users' || type === 'employees' || type === 'employers') {
|
||||
switchTab('users');
|
||||
} else if (type === 'vacancies') {
|
||||
switchTab('vacancies');
|
||||
@@ -1406,6 +1443,35 @@
|
||||
}
|
||||
|
||||
// ========== РАБОТА С ТЕГАМИ ==========
|
||||
async function loadPopularTags() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/tags`);
|
||||
const tags = await response.json();
|
||||
|
||||
const tagsCloud = document.getElementById('tagsCloud');
|
||||
tagsCloud.innerHTML = tags.slice(0, 20).map(t => `
|
||||
<div class="tag-item">
|
||||
${escapeHtml(t.name)}
|
||||
<span class="count">0</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const popularTagsTable = document.getElementById('popularTagsTable');
|
||||
popularTagsTable.innerHTML = tags.slice(0, 10).map(t => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.name)}</strong></td>
|
||||
<td>${escapeHtml(t.category || '—')}</td>
|
||||
<td>0</td>
|
||||
<td>0</td>
|
||||
<td>0</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading tags:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showAddTagModal() {
|
||||
document.getElementById('tagModal').classList.add('active');
|
||||
}
|
||||
@@ -1441,7 +1507,7 @@
|
||||
|
||||
showNotification('Тег создан', 'success');
|
||||
closeTagModal();
|
||||
loadStats(); // Обновляем статистику
|
||||
loadPopularTags();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating tag:', error);
|
||||
@@ -1449,7 +1515,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ЭКРАНИРОВАНИЕ HTML ==========
|
||||
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
||||
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]];
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
if (!unsafe) return '';
|
||||
return unsafe.toString()
|
||||
@@ -1483,6 +1554,7 @@
|
||||
window.onclick = function(event) {
|
||||
const tagModal = document.getElementById('tagModal');
|
||||
const resumeModal = document.getElementById('resumeViewModal');
|
||||
const companyModal = document.getElementById('companyViewModal');
|
||||
|
||||
if (event.target === tagModal) {
|
||||
tagModal.classList.remove('active');
|
||||
@@ -1490,6 +1562,9 @@
|
||||
if (event.target === resumeModal) {
|
||||
resumeModal.classList.remove('active');
|
||||
}
|
||||
if (event.target === companyModal) {
|
||||
companyModal.classList.remove('active');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== ИНИЦИАЛИЗАЦИЯ ==========
|
||||
@@ -1497,7 +1572,7 @@
|
||||
updateNavigation();
|
||||
loadStats();
|
||||
loadUsers();
|
||||
// Не загружаем все сразу, только активный таб
|
||||
loadCompanies();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user