Files
yarmarka/templates/applications.html
T
2026-03-13 19:58:52 +03:00

1040 lines
33 KiB
HTML

<!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: 1200px;
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;
}
.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;
}
.profile-link {
display: flex;
align-items: center;
gap: 8px;
background: #3b82f6;
padding: 8px 20px !important;
}
.applications-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.applications-header h1 {
font-size: 32px;
color: #0b1c34;
display: flex;
align-items: center;
gap: 10px;
}
.applications-header h1 i {
color: #3b82f6;
}
.type-selector {
display: flex;
gap: 10px;
background: white;
padding: 8px;
border-radius: 50px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.type-btn {
padding: 10px 24px;
border-radius: 40px;
cursor: pointer;
font-weight: 600;
transition: 0.2s;
color: #4f7092;
border: none;
background: transparent;
}
.type-btn:hover {
background: #eef4fa;
}
.type-btn.active {
background: #0b1c34;
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 30px;
padding: 25px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
transition: 0.2s;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 15px 35px rgba(0,20,40,0.15);
}
.stat-card.pending { border-left: 5px solid #f59e0b; }
.stat-card.viewed { border-left: 5px solid #3b82f6; }
.stat-card.accepted { border-left: 5px solid #10b981; }
.stat-card.rejected { border-left: 5px solid #ef4444; }
.stat-value {
font-size: 36px;
font-weight: 700;
color: #0b1c34;
margin-bottom: 5px;
}
.stat-label {
color: #4f7092;
font-size: 14px;
font-weight: 500;
}
.filters {
background: white;
border-radius: 30px;
padding: 20px;
margin-bottom: 30px;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
flex: 1;
min-width: 200px;
}
.filter-group select,
.filter-group input {
width: 100%;
padding: 12px 16px;
border: 2px solid #dee9f5;
border-radius: 30px;
font-size: 14px;
background: white;
}
.applications-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.application-card {
background: white;
border-radius: 30px;
padding: 25px;
transition: 0.2s;
cursor: pointer;
border: 1px solid transparent;
}
.application-card:hover {
border-color: #3b82f6;
box-shadow: 0 10px 30px rgba(59,130,246,0.1);
}
.application-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.vacancy-title {
font-size: 18px;
font-weight: 600;
color: #0b1c34;
}
.company-name {
color: #3b82f6;
font-size: 14px;
margin-top: 3px;
}
.status-badge {
padding: 6px 16px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
}
.status-badge.pending {
background: #fef3c7;
color: #92400e;
}
.status-badge.viewed {
background: #dbeafe;
color: #1e40af;
}
.status-badge.accepted {
background: #d1fae5;
color: #065f46;
}
.status-badge.rejected {
background: #fee2e2;
color: #b91c1c;
}
.application-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin: 15px 0;
padding: 15px 0;
border-top: 1px solid #dee9f5;
border-bottom: 1px solid #dee9f5;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
color: #1f3f60;
font-size: 14px;
}
.info-item i {
color: #3b82f6;
width: 18px;
}
.application-message {
background: #f9fcff;
border-radius: 20px;
padding: 15px;
margin: 15px 0;
color: #1f3f60;
font-style: italic;
}
.application-footer {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.application-date {
color: #4f7092;
font-size: 13px;
display: flex;
align-items: center;
gap: 5px;
}
.action-buttons {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 16px;
border-radius: 30px;
border: none;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
transition: 0.2s;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.btn-primary {
background: #0b1c34;
color: white;
}
.btn-primary:hover {
background: #1b3f6b;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.btn-warning {
background: #f59e0b;
color: white;
}
.btn-warning:hover {
background: #d97706;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-outline {
background: transparent;
border: 2px solid #dee9f5;
color: #1f3f60;
}
.btn-outline:hover {
background: #eef4fa;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 40px;
flex-wrap: wrap;
}
.page-btn {
padding: 10px 16px;
border: 2px solid #dee9f5;
background: white;
border-radius: 30px;
cursor: pointer;
transition: 0.2s;
min-width: 45px;
}
.page-btn:hover {
background: #eef4fa;
}
.page-btn.active {
background: #0b1c34;
color: white;
border-color: #0b1c34;
}
.loading {
text-align: center;
padding: 60px;
color: #4f7092;
}
.empty-state {
text-align: center;
padding: 60px;
background: white;
border-radius: 40px;
}
.empty-state i {
font-size: 64px;
color: #cbd5e1;
margin-bottom: 20px;
}
.empty-state h3 {
color: #0b1c34;
margin-bottom: 10px;
}
.empty-state p {
color: #4f7092;
margin-bottom: 30px;
}
.modal {
display: none;
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
max-width: 500px;
width: 90%;
border-radius: 40px;
padding: 40px;
max-height: 90vh;
overflow-y: auto;
}
.modal-content h2 {
color: #0b1c34;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
color: #1f3f60;
margin-bottom: 8px;
}
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #dee9f5;
border-radius: 30px;
font-size: 14px;
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 30px;
}
.modal-actions button {
flex: 1;
padding: 14px;
}
#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; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
</div>
</div>
<div class="applications-header">
<h1>
<i class="fas fa-paper-plane"></i>
Отклики
</h1>
<div class="type-selector" id="typeSelector">
<button class="type-btn active" data-type="received">Полученные</button>
<button class="type-btn" data-type="sent">Отправленные</button>
</div>
</div>
<!-- Статистика -->
<div class="stats-grid" id="statsContainer">
<div class="stat-card pending" data-status="pending">
<div class="stat-value" id="pendingCount">0</div>
<div class="stat-label">Ожидают</div>
</div>
<div class="stat-card viewed" data-status="viewed">
<div class="stat-value" id="viewedCount">0</div>
<div class="stat-label">Просмотрены</div>
</div>
<div class="stat-card accepted" data-status="accepted">
<div class="stat-value" id="acceptedCount">0</div>
<div class="stat-label">Приняты</div>
</div>
<div class="stat-card rejected" data-status="rejected">
<div class="stat-value" id="rejectedCount">0</div>
<div class="stat-label">Отклонены</div>
</div>
</div>
<!-- Фильтры -->
<div class="filters">
<div class="filter-group">
<select id="statusFilter" onchange="applyFilters()">
<option value="">Все статусы</option>
<option value="pending">Ожидают</option>
<option value="viewed">Просмотрены</option>
<option value="accepted">Приняты</option>
<option value="rejected">Отклонены</option>
</select>
</div>
<div class="filter-group">
<input type="text" id="searchFilter" placeholder="Поиск..." oninput="debounceSearch()">
</div>
</div>
<!-- Список откликов -->
<div id="applicationsList" class="applications-list">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i> Загрузка откликов...
</div>
</div>
<!-- Пагинация -->
<div class="pagination" id="pagination"></div>
</div>
<!-- Модальное окно для ответа на отклик -->
<div class="modal" id="responseModal">
<div class="modal-content">
<h2>Ответ на отклик</h2>
<div class="form-group">
<label>Статус</label>
<select id="responseStatus">
<option value="accepted">Принять</option>
<option value="rejected">Отклонить</option>
</select>
</div>
<div class="form-group">
<label>Сообщение (необязательно)</label>
<textarea id="responseMessage" placeholder="Напишите ответ соискателю..."></textarea>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="submitResponse()">Отправить</button>
<button class="btn btn-outline" onclick="closeResponseModal()">Отмена</button>
</div>
</div>
</div>
<div id="notification"></div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
let currentUser = null;
let currentType = 'received'; // 'received' или 'sent'
let currentPage = 1;
let totalPages = 1;
let currentStatus = '';
let currentSearch = '';
let searchTimeout;
let selectedApplicationId = null;
// Проверка авторизации
const token = localStorage.getItem('accessToken');
if (!token) {
window.location.href = '/login';
}
// Проверка авторизации и получение данных пользователя
async function checkAuth() {
try {
const response = await fetch(`${API_BASE_URL}/user`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
currentUser = await response.json();
// Определяем доступные типы откликов в зависимости от роли
const typeSelector = document.getElementById('typeSelector');
if (currentUser.role === 'employee') {
typeSelector.innerHTML = `
<button class="type-btn active" data-type="sent">Отправленные</button>
`;
currentType = 'sent';
} else if (currentUser.role === 'employer') {
typeSelector.innerHTML = `
<button class="type-btn active" data-type="received">Полученные</button>
`;
currentType = 'received';
}
} else {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
} catch (error) {
console.error('Error checking auth:', error);
}
updateNavigation();
loadStats();
loadApplications();
}
// Обновление навигации
function updateNavigation() {
const nav = document.getElementById('nav');
if (currentUser) {
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications" class="active">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
</a>
`;
}
}
// Загрузка статистики
async function loadStats() {
try {
const response = await fetch(`${API_BASE_URL}/applications/stats`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const stats = await response.json();
document.getElementById('pendingCount').textContent = stats.pending || 0;
document.getElementById('viewedCount').textContent = stats.viewed || 0;
document.getElementById('acceptedCount').textContent = stats.accepted || 0;
document.getElementById('rejectedCount').textContent = stats.rejected || 0;
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Загрузка откликов
async function loadApplications() {
try {
let url = `${API_BASE_URL}/applications/${currentType}?page=${currentPage}`;
if (currentStatus) {
url += `&status=${currentStatus}`;
}
// Поиск пока не реализован на бэкенде, но можно добавить позже
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Ошибка загрузки');
const data = await response.json();
totalPages = data.total_pages || 1;
renderApplications(data.applications);
renderPagination();
} catch (error) {
console.error('Error loading applications:', error);
document.getElementById('applicationsList').innerHTML =
'<div class="loading">Ошибка загрузки откликов</div>';
}
}
// Отображение откликов
function renderApplications(applications) {
const container = document.getElementById('applicationsList');
if (!applications || applications.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="far fa-paper-plane"></i>
<h3>Нет откликов</h3>
<p>${currentType === 'received' ? 'У вас пока нет полученных откликов' : 'Вы еще не откликались на вакансии'}</p>
${currentType === 'sent' ?
'<a href="/vacancies" class="btn btn-primary">Найти вакансии</a>' :
''}
</div>
`;
return;
}
container.innerHTML = applications.map(app => {
const date = new Date(app.created_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const statusText = {
'pending': 'Ожидает',
'viewed': 'Просмотрено',
'accepted': 'Принято',
'rejected': 'Отклонено'
}[app.status] || app.status;
if (currentType === 'received') {
// Для работодателя (полученные)
return `
<div class="application-card" onclick="viewApplication(${app.id})">
<div class="application-header">
<div>
<div class="vacancy-title">${escapeHtml(app.vacancy_title)}</div>
</div>
<span class="status-badge ${app.status}">${statusText}</span>
</div>
<div class="application-info">
<div class="info-item">
<i class="fas fa-user"></i>
<span>${escapeHtml(app.user_name)}</span>
</div>
<div class="info-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(app.user_email)}</span>
</div>
<div class="info-item">
<i class="fab fa-telegram"></i>
<span>${escapeHtml(app.user_telegram || '—')}</span>
</div>
</div>
${app.message ? `
<div class="application-message">
<i class="fas fa-quote-left"></i>
${escapeHtml(app.message.substring(0, 100))}${app.message.length > 100 ? '...' : ''}
</div>
` : ''}
<div class="application-footer">
<span class="application-date">
<i class="far fa-clock"></i> ${date}
</span>
<div class="action-buttons" onclick="event.stopPropagation()">
${app.status === 'pending' ? `
<button class="btn btn-success btn-sm" onclick="openResponseModal(${app.id})">
<i class="fas fa-check"></i> Ответить
</button>
` : ''}
${app.response_message ? `
<span class="info-item">
<i class="fas fa-reply"></i> Есть ответ
</span>
` : ''}
</div>
</div>
</div>
`;
} else {
// Для соискателя (отправленные)
return `
<div class="application-card" onclick="viewApplication(${app.id})">
<div class="application-header">
<div>
<div class="vacancy-title">${escapeHtml(app.vacancy_title)}</div>
<div class="company-name">${escapeHtml(app.company_name || 'Компания')}</div>
</div>
<span class="status-badge ${app.status}">${statusText}</span>
</div>
<div class="application-info">
<div class="info-item">
<i class="fas fa-building"></i>
<span>${escapeHtml(app.company_name || 'Компания')}</span>
</div>
<div class="info-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(app.employer_email || '—')}</span>
</div>
</div>
${app.message ? `
<div class="application-message">
<i class="fas fa-quote-left"></i>
${escapeHtml(app.message.substring(0, 100))}${app.message.length > 100 ? '...' : ''}
</div>
` : ''}
${app.response_message ? `
<div class="application-message" style="background: #e6f7e6;">
<i class="fas fa-reply"></i>
<strong>Ответ работодателя:</strong><br>
${escapeHtml(app.response_message)}
</div>
` : ''}
<div class="application-footer">
<span class="application-date">
<i class="far fa-clock"></i> ${date}
</span>
${app.response_at ? `
<span class="application-date">
<i class="fas fa-reply"></i> ${new Date(app.response_at).toLocaleDateString()}
</span>
` : ''}
</div>
</div>
`;
}
}).join('');
}
// Просмотр деталей отклика
async function viewApplication(applicationId) {
window.location.href = `/application/${applicationId}`;
}
// Открыть модальное окно для ответа
function openResponseModal(applicationId) {
selectedApplicationId = applicationId;
document.getElementById('responseModal').classList.add('active');
}
// Закрыть модальное окно
function closeResponseModal() {
document.getElementById('responseModal').classList.remove('active');
selectedApplicationId = null;
document.getElementById('responseMessage').value = '';
}
// Отправить ответ
async function submitResponse() {
if (!selectedApplicationId) return;
const status = document.getElementById('responseStatus').value;
const message = document.getElementById('responseMessage').value;
try {
const response = await fetch(`${API_BASE_URL}/applications/${selectedApplicationId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
status: status,
response_message: message || null
})
});
if (response.ok) {
showNotification('Ответ отправлен', 'success');
closeResponseModal();
loadApplications();
loadStats();
} else {
const error = await response.json();
throw new Error(error.detail);
}
} catch (error) {
showNotification(error.message, 'error');
}
}
// Фильтрация
function applyFilters() {
currentStatus = document.getElementById('statusFilter').value;
currentPage = 1;
loadApplications();
}
// Debounce для поиска
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('searchFilter').value;
currentPage = 1;
loadApplications();
}, 500);
}
// Пагинация
function renderPagination() {
const pagination = document.getElementById('pagination');
let html = '';
for (let i = 1; i <= totalPages; i++) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="goToPage(${i})">${i}</button>`;
}
pagination.innerHTML = html;
}
function goToPage(page) {
currentPage = page;
loadApplications();
}
// Клик по статистике
document.querySelectorAll('.stat-card').forEach(card => {
card.addEventListener('click', () => {
const status = card.dataset.status;
document.getElementById('statusFilter').value = status;
applyFilters();
});
});
// Переключение между полученными и отправленными
document.querySelectorAll('.type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentType = btn.dataset.type;
currentPage = 1;
loadApplications();
});
});
// Функция для показа уведомлений
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
notification.className = type;
notification.innerHTML = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// Экранирование 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;");
}
// Загрузка при старте
checkAuth();
</script>
</body>
</html>