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

1042 lines
34 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: 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>
МП.Ярмарка
</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 currentProtocol = window.location.protocol; // http: или https:
const currentHost = window.location.host; // yarmarka.rabota.today или IP:порт
let API_BASE_URL = `${currentProtocol}//${currentHost}/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>