Files
minicrm/public/client.html
2026-02-26 11:28:17 +05:00

1745 lines
63 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>Клиент внешнего API - Управление задачами</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #f5f7fa;
color: #2c3e50;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.header h1 i {
color: #3498db;
}
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.user-info span {
font-weight: 500;
color: #7f8c8d;
}
.logout-btn {
background: #e74c3c;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.logout-btn:hover {
background: #c0392b;
}
.connection-panel {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.panel-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel-title i {
color: #3498db;
}
.connection-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 15px;
align-items: end;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group label {
font-size: 14px;
font-weight: 500;
color: #7f8c8d;
}
.form-group input, .form-group select, .form-group textarea {
padding: 10px 12px;
border: 1px solid #dce4ec;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.form-group input.error {
border-color: #e74c3c;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover {
background: #e67e22;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.saved-connections {
margin-top: 20px;
border-top: 1px solid #ecf0f1;
padding-top: 20px;
}
.connections-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.connection-item {
background: #ecf0f1;
padding: 10px 15px;
border-radius: 5px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background 0.3s;
}
.connection-item:hover {
background: #d5dbdb;
}
.connection-item.active {
background: #3498db;
color: white;
}
.connection-item .remove-conn {
color: #e74c3c;
cursor: pointer;
font-size: 18px;
padding: 0 5px;
}
.connection-item .remove-conn:hover {
color: #c0392b;
}
.filter-panel {
background: white;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: 15px;
align-items: end;
}
.tasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.tasks-header h2 {
font-size: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.tasks-header h2 i {
color: #3498db;
}
.tasks-count {
background: #3498db;
color: white;
padding: 5px 10px;
border-radius: 20px;
font-size: 14px;
}
.tasks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.task-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
transition: transform 0.3s, box-shadow 0.3s;
display: flex;
flex-direction: column;
}
.task-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.task-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
flex: 1;
word-break: break-word;
}
.task-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-assigned { background: #3498db; color: white; }
.status-in_progress { background: #f39c12; color: white; }
.status-completed { background: #27ae60; color: white; }
.status-overdue { background: #e74c3c; color: white; }
.status-rework { background: #9b59b6; color: white; }
.status-default { background: #95a5a6; color: white; }
.task-description {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 15px;
max-height: 100px;
overflow-y: auto;
word-break: break-word;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
font-size: 12px;
color: #95a5a6;
}
.task-meta-item {
display: flex;
align-items: center;
gap: 5px;
}
.task-files {
margin-bottom: 15px;
}
.files-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 5px;
}
.files-list {
list-style: none;
max-height: 150px;
overflow-y: auto;
border: 1px solid #ecf0f1;
border-radius: 5px;
padding: 5px;
}
.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px;
border-bottom: 1px solid #ecf0f1;
font-size: 12px;
}
.file-item:last-child {
border-bottom: none;
}
.file-icon {
color: #3498db;
}
.file-name {
flex: 1;
word-break: break-word;
}
.file-size {
color: #95a5a6;
font-size: 10px;
}
.file-download {
color: #27ae60;
cursor: pointer;
text-decoration: none;
font-size: 14px;
}
.file-download:hover {
color: #229954;
}
.task-actions {
display: flex;
gap: 10px;
margin-top: auto;
flex-wrap: wrap;
}
.task-action-btn {
flex: 1;
padding: 8px;
border: none;
border-radius: 5px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 80px;
}
.action-progress {
background: #f39c12;
color: white;
}
.action-progress:hover {
background: #e67e22;
}
.action-complete {
background: #27ae60;
color: white;
}
.action-complete:hover {
background: #229954;
}
.action-upload {
background: #3498db;
color: white;
}
.action-upload:hover {
background: #2980b9;
}
.action-sync {
background: #9b59b6;
color: white;
}
.action-sync:hover {
background: #8e44ad;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 30px;
}
.pagination-btn {
padding: 8px 12px;
border: 1px solid #dce4ec;
background: white;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s;
}
.pagination-btn:hover:not(:disabled) {
background: #3498db;
color: white;
border-color: #3498db;
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-info {
padding: 8px 12px;
background: #ecf0f1;
border-radius: 5px;
}
.loading {
text-align: center;
padding: 50px;
color: #7f8c8d;
}
.loading i {
animation: spin 1s linear infinite;
font-size: 30px;
color: #3498db;
}
.loading-small {
text-align: center;
padding: 10px;
color: #7f8c8d;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.no-tasks {
text-align: center;
padding: 50px;
color: #7f8c8d;
grid-column: 1 / -1;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 10px;
padding: 30px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
font-size: 20px;
}
.modal-close {
font-size: 24px;
cursor: pointer;
color: #7f8c8d;
}
.modal-close:hover {
color: #2c3e50;
}
.upload-area {
border: 2px dashed #dce4ec;
border-radius: 5px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: border-color 0.3s;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #3498db;
}
.upload-area i {
font-size: 40px;
color: #3498db;
margin-bottom: 10px;
}
.upload-area p {
color: #7f8c8d;
}
.file-input {
display: none;
}
.selected-files {
margin-bottom: 20px;
}
.selected-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
background: #ecf0f1;
border-radius: 5px;
margin-bottom: 5px;
}
.selected-file .file-name {
flex: 1;
font-size: 13px;
}
.selected-file .file-size {
color: #7f8c8d;
font-size: 12px;
}
.selected-file .remove-file {
color: #e74c3c;
cursor: pointer;
}
.upload-progress, .sync-progress {
margin-top: 15px;
padding: 10px;
background: #ecf0f1;
border-radius: 5px;
}
.progress-bar {
height: 10px;
background: #dce4ec;
border-radius: 5px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #27ae60;
width: 0%;
transition: width 0.3s;
}
.sync-status {
margin-top: 10px;
padding: 10px;
background: #ecf0f1;
border-radius: 5px;
font-size: 13px;
line-height: 1.8;
}
.alert {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 5px;
margin-bottom: 20px;
display: none;
z-index: 2000;
max-width: 400px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
.alert.show {
display: block;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-danger {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.server-info {
margin-top: 15px;
padding: 10px;
background: #ecf0f1;
border-radius: 5px;
font-size: 13px;
color: #2c3e50;
}
.refresh-btn {
background: #3498db;
color: white;
border: none;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.refresh-btn:hover {
background: #2980b9;
}
.badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.badge-success {
background: #27ae60;
color: white;
}
.badge-warning {
background: #f39c12;
color: white;
}
.badge-info {
background: #3498db;
color: white;
}
small {
color: #95a5a6;
font-size: 12px;
margin-top: 3px;
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div class="container">
<!-- Шапка -->
<div class="header">
<h1>
<i class="fas fa-cloud-upload-alt"></i>
Клиент внешнего API
</h1>
<div class="user-info">
<span id="userName">Загрузка...</span>
<button class="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> Выход
</button>
</div>
</div>
<!-- Подключение к серверу -->
<div class="connection-panel">
<div class="panel-title">
<i class="fas fa-plug"></i>
Подключение к внешнему сервису https://minicrm.it25.su 940b4570dc4f43280b038b4417aac4cbb2133dbd98f22303d5c47a947f479a13
</div>
<div class="connection-form">
<div class="form-group">
<label>URL сервиса</label>
<input type="url" id="apiUrl" placeholder="https://example.com" value="">
</div>
<div class="form-group">
<label>API ключ</label>
<input type="text" id="apiKey" placeholder="Введите API ключ">
</div>
<button class="btn btn-primary" onclick="connect()" id="connectBtn">
<i class="fas fa-link"></i> Подключиться
</button>
</div>
<!-- Сохраненные подключения -->
<div class="saved-connections">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-weight: 500;">Сохраненные подключения:</span>
<button class="btn btn-secondary" onclick="loadSavedConnections()" style="padding: 5px 10px;">
<i class="fas fa-sync-alt"></i> Обновить
</button>
</div>
<div class="connections-list" id="connectionsList">
<div class="loading-small">Загрузка...</div>
</div>
</div>
<!-- Информация о сервере -->
<div id="serverInfo" class="server-info" style="display: none;"></div>
</div>
<!-- Фильтры -->
<div class="filter-panel">
<div class="form-group">
<label>Статус задач</label>
<select id="statusFilter">
<option value="">Все</option>
<option value="assigned">Назначена</option>
<option value="in_progress">В работе</option>
<option value="completed">Выполнена</option>
<option value="overdue">Просрочена</option>
<option value="rework">На доработке</option>
</select>
</div>
<div class="form-group">
<label>Поиск</label>
<input type="text" id="searchFilter" placeholder="Поиск по названию и описанию">
</div>
<div class="form-group">
<label>Лимит</label>
<select id="limitFilter">
<option value="20">20 задач</option>
<option value="50" selected>50 задач</option>
<option value="100">100 задач</option>
<option value="200">200 задач</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadTasks()" id="loadTasksBtn" disabled>
<i class="fas fa-search"></i> Загрузить задачи
</button>
</div>
<!-- Заголовок задач -->
<div class="tasks-header">
<h2>
<i class="fas fa-tasks"></i>
Задачи из внешнего сервиса
</h2>
<div style="display: flex; gap: 15px; align-items: center;">
<span class="tasks-count" id="tasksCount">0</span>
<button class="refresh-btn" onclick="loadTasks()" id="refreshTasksBtn" disabled>
<i class="fas fa-sync-alt"></i> Обновить
</button>
</div>
</div>
<!-- Список задач -->
<div id="tasksContainer" class="tasks-grid">
<div class="loading">
<i class="fas fa-circle-notch"></i>
<p>Подключитесь к серверу для загрузки задач</p>
</div>
</div>
<!-- Пагинация -->
<div class="pagination" id="pagination" style="display: none;">
<button class="pagination-btn" id="prevPage" onclick="changePage(-1)" disabled>
<i class="fas fa-chevron-left"></i> Предыдущая
</button>
<span class="pagination-info" id="pageInfo">Страница 1 из 1</span>
<button class="pagination-btn" id="nextPage" onclick="changePage(1)" disabled>
Следующая <i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<!-- Модальное окно загрузки файлов -->
<div class="modal" id="uploadModal">
<div class="modal-content">
<div class="modal-header">
<h3>Загрузка файлов</h3>
<span class="modal-close" onclick="closeUploadModal()">&times;</span>
</div>
<div class="upload-area" onclick="document.getElementById('fileInput').click()">
<i class="fas fa-cloud-upload-alt"></i>
<p>Нажмите для выбора файлов или перетащите их сюда</p>
<p style="font-size: 12px; color: #95a5a6;">Максимум 15 файлов</p>
</div>
<input type="file" id="fileInput" class="file-input" multiple onchange="handleFileSelect()">
<div class="selected-files" id="selectedFiles" style="display: none;">
<div style="font-weight: 500; margin-bottom: 10px;">Выбранные файлы:</div>
<div id="filesList"></div>
</div>
<div class="upload-progress" id="uploadProgress" style="display: none;">
<div style="margin-bottom: 5px;">Загрузка: <span id="progressPercent">0%</span></div>
<div class="progress-bar">
<div class="progress-fill" id="progressBar" style="width: 0%;"></div>
</div>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeUploadModal()">Отмена</button>
<button class="btn btn-success" onclick="uploadFiles()" id="uploadBtn" disabled>
<i class="fas fa-upload"></i> Загрузить
</button>
</div>
</div>
</div>
<!-- Модальное окно синхронизации задачи -->
<div class="modal" id="syncModal">
<div class="modal-content">
<div class="modal-header">
<h3>Синхронизация задачи</h3>
<span class="modal-close" onclick="closeSyncModal()">&times;</span>
</div>
<div style="margin-bottom: 20px;">
<p>Вы синхронизируете задачу: <strong id="syncTaskTitle"></strong></p>
<p style="font-size: 14px; color: #7f8c8d; margin-top: 5px;">
<i class="fas fa-info-circle"></i> Исполнители будут сохранены как в исходной задаче
</p>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Целевой сервис <span style="color: #e74c3c;">*</span></label>
<select id="targetService" onchange="toggleTargetServiceInput()">
<option value="">-- Выберите сервис --</option>
<optgroup label="Сохраненные подключения" id="savedTargetConnections"></optgroup>
<option value="new">Указать новый сервис</option>
</select>
</div>
<div id="newServiceInputs" style="display: none;">
<div class="form-group" style="margin-bottom: 15px;">
<label>URL сервиса</label>
<input type="url" id="targetApiUrl" placeholder="https://example.com">
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>API ключ</label>
<input type="text" id="targetApiKey" placeholder="Введите API ключ">
</div>
</div>
<div class="form-group" style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="syncFiles" checked>
<span>Синхронизировать файлы</span>
</label>
<small style="display: block; margin-top: 5px; color: #7f8c8d;">
<i class="fas fa-exchange-alt"></i> При синхронизации задача будет обновлена в целевой системе,
если она там уже существует, или создана новая.
</small>
</div>
<div id="syncProgress" style="display: none;">
<div style="margin-bottom: 5px;">Синхронизация: <span id="syncPercent">0%</span></div>
<div class="progress-bar">
<div class="progress-fill" id="syncProgressBar" style="width: 0%;"></div>
</div>
<div id="syncStatus" class="sync-status"></div>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button class="btn btn-secondary" onclick="closeSyncModal()">Отмена</button>
<button class="btn btn-success" onclick="syncTask()" id="syncBtn">
<i class="fas fa-sync-alt"></i> Синхронизировать задачу
</button>
</div>
</div>
</div>
<!-- Уведомления -->
<div id="alert" class="alert"></div>
<script>
// Глобальные переменные
let currentConnectionId = null;
let currentTasks = [];
let currentPage = 0;
let totalTasks = 0;
let pageSize = 50;
let currentTaskId = null;
let selectedFiles = [];
let syncTaskId = null;
let syncTaskTitle = '';
// Проверка авторизации
async function checkAuth() {
try {
const response = await fetch('/api/user');
const data = await response.json();
if (data.user) {
document.getElementById('userName').textContent = data.user.name || data.user.login;
} else {
window.location.href = '/';
}
} catch (error) {
console.error('Ошибка проверки авторизации:', error);
window.location.href = '/';
}
}
// Выход
async function logout() {
try {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/';
} catch (error) {
console.error('Ошибка при выходе:', error);
}
}
// Загрузка сохраненных подключений
async function loadSavedConnections() {
try {
const response = await fetch('/api/client/connections');
const data = await response.json();
const connectionsList = document.getElementById('connectionsList');
if (data.success && data.connections.length > 0) {
connectionsList.innerHTML = data.connections.map(conn => `
<div class="connection-item ${currentConnectionId === conn.id ? 'active' : ''}"
onclick="selectConnection('${conn.id}')">
<i class="fas fa-database"></i>
<span>${conn.name}</span>
<span class="remove-conn" onclick="event.stopPropagation(); removeConnection('${conn.id}')">
<i class="fas fa-times"></i>
</span>
</div>
`).join('');
} else {
connectionsList.innerHTML = '<div class="connection-item">Нет сохраненных подключений</div>';
}
} catch (error) {
console.error('Ошибка загрузки подключений:', error);
document.getElementById('connectionsList').innerHTML =
'<div class="connection-item">Ошибка загрузки</div>';
}
}
// Выбор сохраненного подключения
function selectConnection(connectionId) {
currentConnectionId = connectionId;
document.querySelectorAll('.connection-item').forEach(el => {
el.classList.remove('active');
});
event.currentTarget.classList.add('active');
document.getElementById('loadTasksBtn').disabled = false;
document.getElementById('refreshTasksBtn').disabled = false;
loadTasks();
}
// Удаление подключения
async function removeConnection(connectionId) {
if (!confirm('Удалить это подключение?')) return;
try {
const response = await fetch(`/api/client/connections/${connectionId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
if (currentConnectionId === connectionId) {
currentConnectionId = null;
document.getElementById('loadTasksBtn').disabled = true;
document.getElementById('refreshTasksBtn').disabled = true;
document.getElementById('tasksContainer').innerHTML =
'<div class="loading"><i class="fas fa-plug"></i><p>Подключитесь к серверу</p></div>';
document.getElementById('tasksCount').textContent = '0';
document.getElementById('serverInfo').style.display = 'none';
}
loadSavedConnections();
showAlert('Подключение удалено', 'success');
}
} catch (error) {
console.error('Ошибка удаления подключения:', error);
showAlert('Ошибка при удалении подключения', 'danger');
}
}
// Подключение к серверу
async function connect() {
const apiUrl = document.getElementById('apiUrl').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
if (!apiUrl || !apiKey) {
showAlert('Заполните URL сервиса и API ключ', 'warning');
return;
}
const connectBtn = document.getElementById('connectBtn');
connectBtn.disabled = true;
connectBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Подключение...';
try {
const response = await fetch('/api/client/connect', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ api_url: apiUrl, api_key: apiKey })
});
const data = await response.json();
if (data.success) {
showAlert('Подключение успешно установлено', 'success');
currentConnectionId = data.connection.id;
const serverInfo = document.getElementById('serverInfo');
serverInfo.innerHTML = `
<i class="fas fa-server"></i>
<strong>Сервер:</strong> ${data.connection.url}<br>
<strong>Пользователь:</strong> ${data.server_info.user}<br>
<strong>Задач на сервере:</strong> ${data.server_info.tasks_count}
`;
serverInfo.style.display = 'block';
loadSavedConnections();
document.getElementById('loadTasksBtn').disabled = false;
document.getElementById('refreshTasksBtn').disabled = false;
document.getElementById('apiUrl').value = '';
document.getElementById('apiKey').value = '';
loadTasks();
} else {
showAlert(data.error || 'Ошибка подключения', 'danger');
}
} catch (error) {
console.error('Ошибка подключения:', error);
showAlert('Ошибка при подключении к серверу', 'danger');
} finally {
connectBtn.disabled = false;
connectBtn.innerHTML = '<i class="fas fa-link"></i> Подключиться';
}
}
// Загрузка задач
async function loadTasks() {
if (!currentConnectionId) {
showAlert('Сначала выберите подключение', 'warning');
return;
}
const statusFilter = document.getElementById('statusFilter').value;
const searchFilter = document.getElementById('searchFilter').value;
const limit = parseInt(document.getElementById('limitFilter').value);
pageSize = limit;
const tasksContainer = document.getElementById('tasksContainer');
tasksContainer.innerHTML = '<div class="loading"><i class="fas fa-circle-notch fa-spin"></i><p>Загрузка задач...</p></div>';
try {
let url = `/api/client/tasks?connection_id=${currentConnectionId}&limit=${limit}&offset=${currentPage * limit}`;
if (statusFilter) url += `&status=${statusFilter}`;
if (searchFilter) url += `&search=${encodeURIComponent(searchFilter)}`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
currentTasks = data.tasks || [];
totalTasks = data.meta.total;
document.getElementById('tasksCount').textContent = totalTasks;
if (currentTasks.length === 0) {
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-folder-open"></i><p>Задачи не найдены</p></div>';
} else {
renderTasks(currentTasks);
}
updatePagination();
} else {
tasksContainer.innerHTML = `<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка: ${data.error || 'Неизвестная ошибка'}</p></div>`;
}
} catch (error) {
console.error('Ошибка загрузки задач:', error);
tasksContainer.innerHTML = '<div class="no-tasks"><i class="fas fa-exclamation-triangle"></i><p>Ошибка загрузки задач</p></div>';
}
}
// Отрисовка задач
function renderTasks(tasks) {
const container = document.getElementById('tasksContainer');
container.innerHTML = tasks.map(task => {
const statusClass = `status-${task.assignment_status || 'default'}`;
const statusText = getStatusText(task.assignment_status);
const createdDate = task.created_at ? new Date(task.created_at).toLocaleString() : 'Н/Д';
const dueDate = task.due_date ? new Date(task.due_date).toLocaleString() : 'Нет срока';
return `
<div class="task-card" id="task-${task.id}">
<div class="task-header">
<div class="task-title">${escapeHtml(task.title || 'Без названия')}</div>
<div class="task-status ${statusClass}">${statusText}</div>
</div>
<div class="task-description">
${escapeHtml(task.description || 'Нет описания')}
</div>
<div class="task-meta">
<div class="task-meta-item">
<i class="fas fa-calendar-alt"></i>
<span>Создана: ${createdDate}</span>
</div>
<div class="task-meta-item">
<i class="fas fa-clock"></i>
<span>Срок: ${dueDate}</span>
</div>
<div class="task-meta-item">
<i class="fas fa-user"></i>
<span>Автор: ${escapeHtml(task.creator_name || 'Н/Д')}</span>
</div>
</div>
${renderFiles(task.files, task.id)}
<div class="task-actions">
${task.assignment_status !== 'completed' ? `
<button class="task-action-btn action-progress" onclick="updateTaskStatus('${task.id}', 'in_progress')">
<i class="fas fa-play"></i> В работу
</button>
<button class="task-action-btn action-complete" onclick="updateTaskStatus('${task.id}', 'completed')">
<i class="fas fa-check"></i> Завершить
</button>
` : ''}
<button class="task-action-btn action-upload" onclick="openUploadModal('${task.id}')">
<i class="fas fa-upload"></i> Файлы
</button>
<button class="task-action-btn action-sync" onclick="openSyncModal('${task.id}', '${escapeHtml(task.title)}')">
<i class="fas fa-sync-alt"></i> Синхронизировать
</button>
</div>
</div>
`;
}).join('');
}
// Отрисовка файлов задачи
function renderFiles(files, taskId) {
if (!files || files.length === 0) {
return '';
}
return `
<div class="task-files">
<div class="files-title">
<i class="fas fa-paperclip"></i>
Файлы (${files.length})
</div>
<ul class="files-list">
${files.map(file => `
<li class="file-item">
<i class="fas fa-file file-icon"></i>
<span class="file-name" title="${escapeHtml(file.filename || file.original_name)}">
${escapeHtml(file.filename || file.original_name)}
</span>
<span class="file-size">${formatFileSize(file.file_size)}</span>
<a href="#" class="file-download" onclick="downloadFile('${taskId}', '${file.id}', '${escapeHtml(file.filename || file.original_name)}'); return false;">
<i class="fas fa-download"></i>
</a>
</li>
`).join('')}
</ul>
</div>
`;
}
// Обновление статуса задачи
async function updateTaskStatus(taskId, status) {
if (!currentConnectionId) return;
const comment = status === 'completed' ?
prompt('Введите комментарий к завершению (необязательно):') :
null;
const button = document.querySelector(`#task-${taskId} .action-${status === 'in_progress' ? 'progress' : 'complete'}`);
if (button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Обновление...';
}
try {
const response = await fetch(`/api/client/tasks/${taskId}/status?connection_id=${currentConnectionId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status, comment })
});
const data = await response.json();
if (data.success) {
showAlert(`Статус задачи изменен на "${getStatusText(status)}"`, 'success');
loadTasks();
} else {
showAlert(data.error || 'Ошибка обновления статуса', 'danger');
if (button) {
button.disabled = false;
button.innerHTML = status === 'in_progress' ?
'<i class="fas fa-play"></i> В работу' :
'<i class="fas fa-check"></i> Завершить';
}
}
} catch (error) {
console.error('Ошибка обновления статуса:', error);
showAlert('Ошибка при обновлении статуса', 'danger');
if (button) {
button.disabled = false;
button.innerHTML = status === 'in_progress' ?
'<i class="fas fa-play"></i> В работу' :
'<i class="fas fa-check"></i> Завершить';
}
}
}
// Открыть модальное окно загрузки файлов
function openUploadModal(taskId) {
currentTaskId = taskId;
document.getElementById('uploadModal').classList.add('active');
selectedFiles = [];
document.getElementById('selectedFiles').style.display = 'none';
document.getElementById('filesList').innerHTML = '';
document.getElementById('uploadBtn').disabled = true;
document.getElementById('uploadProgress').style.display = 'none';
document.getElementById('progressBar').style.width = '0%';
document.getElementById('progressPercent').textContent = '0%';
}
// Закрыть модальное окно загрузки
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('active');
currentTaskId = null;
}
// Обработка выбора файлов
function handleFileSelect() {
const files = document.getElementById('fileInput').files;
selectedFiles = Array.from(files);
if (selectedFiles.length > 0) {
const filesList = document.getElementById('filesList');
filesList.innerHTML = selectedFiles.map((file, index) => `
<div class="selected-file">
<i class="fas fa-file"></i>
<span class="file-name">${escapeHtml(file.name)}</span>
<span class="file-size">${formatFileSize(file.size)}</span>
<span class="remove-file" onclick="removeFile(${index})">
<i class="fas fa-times"></i>
</span>
</div>
`).join('');
document.getElementById('selectedFiles').style.display = 'block';
document.getElementById('uploadBtn').disabled = false;
} else {
document.getElementById('selectedFiles').style.display = 'none';
document.getElementById('uploadBtn').disabled = true;
}
}
// Удалить файл из списка
function removeFile(index) {
selectedFiles.splice(index, 1);
if (selectedFiles.length > 0) {
const filesList = document.getElementById('filesList');
filesList.innerHTML = selectedFiles.map((file, i) => `
<div class="selected-file">
<i class="fas fa-file"></i>
<span class="file-name">${escapeHtml(file.name)}</span>
<span class="file-size">${formatFileSize(file.size)}</span>
<span class="remove-file" onclick="removeFile(${i})">
<i class="fas fa-times"></i>
</span>
</div>
`).join('');
} else {
document.getElementById('selectedFiles').style.display = 'none';
document.getElementById('uploadBtn').disabled = true;
}
}
// Загрузка файлов
async function uploadFiles() {
if (!currentConnectionId || !currentTaskId || selectedFiles.length === 0) return;
const formData = new FormData();
selectedFiles.forEach(file => {
formData.append('files', file);
});
const uploadBtn = document.getElementById('uploadBtn');
const progressDiv = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const progressPercent = document.getElementById('progressPercent');
uploadBtn.disabled = true;
progressDiv.style.display = 'block';
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
if (data.success) {
showAlert(`Успешно загружено ${selectedFiles.length} файлов`, 'success');
closeUploadModal();
loadTasks();
} else {
showAlert(data.error || 'Ошибка загрузки файлов', 'danger');
uploadBtn.disabled = false;
}
} else {
showAlert('Ошибка загрузки файлов', 'danger');
uploadBtn.disabled = false;
}
});
xhr.addEventListener('error', () => {
showAlert('Ошибка сети при загрузке', 'danger');
uploadBtn.disabled = false;
});
xhr.open('POST', `/api/client/tasks/${currentTaskId}/files?connection_id=${currentConnectionId}`, true);
xhr.send(formData);
} catch (error) {
console.error('Ошибка загрузки файлов:', error);
showAlert('Ошибка при загрузке файлов', 'danger');
uploadBtn.disabled = false;
}
}
// Скачать файл
async function downloadFile(taskId, fileId, fileName) {
if (!currentConnectionId) return;
try {
const response = await fetch(`/api/client/tasks/${taskId}/files/${fileId}/download?connection_id=${currentConnectionId}`);
if (!response.ok) {
throw new Error('Ошибка скачивания');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (error) {
console.error('Ошибка скачивания файла:', error);
showAlert('Ошибка при скачивании файла', 'danger');
}
}
// Загрузка списка подключений для синхронизации
async function loadTargetConnections() {
try {
const response = await fetch('/api/client/connections/list');
const data = await response.json();
if (data.success) {
const select = document.getElementById('savedTargetConnections');
select.innerHTML = data.connections.map(conn =>
`<option value="${conn.id}">${conn.name} (${conn.url})</option>`
).join('');
}
} catch (error) {
console.error('Ошибка загрузки подключений:', error);
}
}
// Открыть модальное окно синхронизации
function openSyncModal(taskId, taskTitle) {
syncTaskId = taskId;
syncTaskTitle = taskTitle;
document.getElementById('syncTaskTitle').textContent = taskTitle;
loadTargetConnections();
document.getElementById('syncModal').classList.add('active');
}
// Закрыть модальное окно синхронизации
function closeSyncModal() {
document.getElementById('syncModal').classList.remove('active');
syncTaskId = null;
document.getElementById('targetService').value = '';
document.getElementById('newServiceInputs').style.display = 'none';
document.getElementById('targetApiUrl').value = '';
document.getElementById('targetApiKey').value = '';
document.getElementById('syncFiles').checked = true;
document.getElementById('syncProgress').style.display = 'none';
document.getElementById('syncBtn').disabled = false;
document.getElementById('syncStatus').innerHTML = '';
}
// Переключение между сохраненным и новым сервисом
function toggleTargetServiceInput() {
const targetService = document.getElementById('targetService').value;
document.getElementById('newServiceInputs').style.display =
targetService === 'new' ? 'block' : 'none';
}
// Синхронизация задачи
async function syncTask() {
if (!syncTaskId) return;
const targetService = document.getElementById('targetService').value;
const syncFiles = document.getElementById('syncFiles').checked;
if (!targetService) {
showAlert('Выберите целевой сервис', 'warning');
return;
}
let targetData = {};
if (targetService === 'new') {
const targetApiUrl = document.getElementById('targetApiUrl').value.trim();
const targetApiKey = document.getElementById('targetApiKey').value.trim();
if (!targetApiUrl || !targetApiKey) {
showAlert('Заполните URL и API ключ целевого сервиса', 'warning');
return;
}
targetData = {
target_api_url: targetApiUrl,
target_api_key: targetApiKey
};
} else {
targetData = {
target_connection_id: targetService
};
}
const requestData = {
...targetData,
sync_files: syncFiles
};
document.getElementById('syncProgress').style.display = 'block';
document.getElementById('syncBtn').disabled = true;
document.getElementById('syncStatus').innerHTML = 'Начинаем синхронизацию...';
try {
const response = await fetch(`/api/client/tasks/${syncTaskId}/sync?connection_id=${currentConnectionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
const data = await response.json();
if (data.success) {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
document.getElementById('syncProgressBar').style.width = progress + '%';
document.getElementById('syncPercent').textContent = progress + '%';
if (progress >= 100) {
clearInterval(interval);
let statusText = `✅ Задача синхронизирована!`;
if (data.data.sync_type === 'created') {
statusText += `<br>📋 Создана новая задача в целевой системе. ID: ${data.data.target_task_id}`;
} else if (data.data.sync_type === 'updated') {
statusText += `<br>🔄 Обновлена существующая задача в целевой системе. ID: ${data.data.target_task_id}`;
}
statusText += `<br>👥 Исполнители: сохранены как в исходной задаче`;
if (data.data.assignees && data.data.assignees.length > 0) {
statusText += `<br>👤 Количество исполнителей: ${data.data.assignees.length}`;
}
if (data.data.synced_files && data.data.synced_files.length > 0) {
const successCount = data.data.synced_files.filter(f => f.success).length;
const failCount = data.data.synced_files.filter(f => !f.success).length;
statusText += `<br>📁 Файлы: ${successCount} синхронизировано, ${failCount} ошибок`;
if (failCount > 0) {
const errors = data.data.synced_files
.filter(f => !f.success)
.map(f => f.original_name)
.join(', ');
statusText += `<br><small style="color: #e74c3c;">Ошибки: ${errors}</small>`;
}
}
if (data.data.warnings && data.data.warnings.length > 0) {
statusText += `<br><small style="color: #f39c12;">⚠️ ${data.data.warnings.join('; ')}</small>`;
}
document.getElementById('syncStatus').innerHTML = statusText;
setTimeout(() => {
closeSyncModal();
showAlert(`Задача синхронизирована с ${data.data.target_service}`, 'success');
loadTasks();
}, 3000);
}
}, 200);
} else {
showAlert(data.error || 'Ошибка синхронизации задачи', 'danger');
document.getElementById('syncProgress').style.display = 'none';
document.getElementById('syncBtn').disabled = false;
document.getElementById('syncStatus').innerHTML = '';
}
} catch (error) {
console.error('Ошибка синхронизации:', error);
showAlert('Ошибка при синхронизации задачи', 'danger');
document.getElementById('syncProgress').style.display = 'none';
document.getElementById('syncBtn').disabled = false;
document.getElementById('syncStatus').innerHTML = '';
}
}
// Смена страницы
function changePage(delta) {
const newPage = currentPage + delta;
if (newPage >= 0 && newPage * pageSize < totalTasks) {
currentPage = newPage;
loadTasks();
}
}
// Обновление пагинации
function updatePagination() {
const totalPages = Math.ceil(totalTasks / pageSize);
if (totalPages > 1) {
document.getElementById('pagination').style.display = 'flex';
document.getElementById('pageInfo').textContent = `Страница ${currentPage + 1} из ${totalPages}`;
document.getElementById('prevPage').disabled = currentPage === 0;
document.getElementById('nextPage').disabled = currentPage >= totalPages - 1;
} else {
document.getElementById('pagination').style.display = 'none';
}
}
// Показать уведомление
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.className = `alert alert-${type} show`;
alert.innerHTML = message;
setTimeout(() => {
alert.classList.remove('show');
}, 5000);
}
// Вспомогательные функции
function getStatusText(status) {
const statusMap = {
'assigned': 'Назначена',
'in_progress': 'В работе',
'completed': 'Выполнена',
'overdue': 'Просрочена',
'rework': 'На доработке'
};
return statusMap[status] || status || 'Неизвестно';
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Б';
const k = 1024;
const sizes = ['Б', 'КБ', 'МБ', 'ГБ'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Инициализация
document.addEventListener('DOMContentLoaded', () => {
checkAuth();
loadSavedConnections();
});
</script>
</body>
</html>