Files
minicrm/public/client.js
2026-03-09 14:20:28 +05:00

755 lines
29 KiB
JavaScript
Raw Permalink 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.
// client.js клиентская логика для работы с внешним API
// Глобальные переменные
let currentConnectionId = null;
let currentTasks = [];
let currentPage = 0;
let totalTasks = 0;
let pageSize = 50;
let currentTaskId = null;
let selectedFiles = [];
let importTaskData = null; // данные импортируемой задачи
let currentUserId = null; // ID текущего пользователя (для фильтрации)
// ==================== АВТОРИЗАЦИЯ ====================
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;
currentUserId = data.user.id; // сохраняем ID
} 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-import" onclick="importTask('${task.id}')">
<i class="fas fa-download"></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> Завершить';
}
}
}
// ==================== ИМПОРТ ЗАДАЧИ В ЛОКАЛЬНУЮ CRM ====================
/**
* Загружает задачу из внешнего сервиса и открывает модальное окно для импорта
*/
async function importTask(taskId) {
if (!currentConnectionId) {
showAlert('Сначала выберите подключение', 'warning');
return;
}
console.log(`[Import] Загрузка задачи ${taskId} из внешнего сервиса...`);
try {
// 1. Получаем задачу из внешнего API
const response = await fetch(`/api/client/tasks/${taskId}?connection_id=${currentConnectionId}`);
const data = await response.json();
if (!data.success || !data.task) {
showAlert('Не удалось получить задачу из внешнего сервиса', 'danger');
return;
}
const task = data.task;
importTaskData = {
title: task.title,
description: task.description || '',
due_date: task.due_date || '',
task_type: task.task_type || 'regular',
files: task.files || []
};
// 2. Загружаем список локальных пользователей (если ещё не загружен)
await loadLocalUsers();
// 3. Открываем модальное окно импорта
openImportModal(task);
} catch (error) {
console.error('Ошибка импорта задачи:', error);
showAlert('Ошибка при загрузке задачи', 'danger');
}
}
/**
* Загружает список локальных пользователей (используется в модальном окне)
*/
let localUsers = [];
async function loadLocalUsers() {
if (localUsers.length > 0) return;
try {
const response = await fetch('/api/users');
if (response.ok) {
localUsers = await response.json();
}
} catch (error) {
console.error('Ошибка загрузки локальных пользователей:', error);
}
}
/**
* Открывает модальное окно импорта задачи
*/
function openImportModal(task) {
const modal = document.getElementById('import-task-modal');
if (!modal) {
console.error('Модальное окно import-task-modal не найдено');
return;
}
// Заполняем данные задачи
document.getElementById('import-task-title').textContent = task.title;
document.getElementById('import-task-description').textContent = task.description || 'Нет описания';
// Устанавливаем дату выполнения
const dueDateInput = document.getElementById('import-due-date');
if (task.due_date) {
const date = new Date(task.due_date);
dueDateInput.value = date.toISOString().slice(0, 16);
} else {
const defaultDate = new Date();
defaultDate.setDate(defaultDate.getDate() + 7);
dueDateInput.value = defaultDate.toISOString().slice(0, 16);
}
// Очищаем и заполняем список исполнителей
const container = document.getElementById('import-users-checklist');
container.innerHTML = '';
// Фильтруем текущего пользователя (нельзя назначить самому себе)
const filteredUsers = localUsers.filter(u => u.id !== currentUserId);
filteredUsers.forEach(user => {
const div = document.createElement('div');
div.className = 'checkbox-item';
div.innerHTML = `
<label>
<input type="checkbox" class="import-user-checkbox" value="${user.id}">
${escapeHtml(user.name)} (${escapeHtml(user.login)})
</label>
`;
container.appendChild(div);
});
// Показываем модальное окно
modal.style.display = 'block';
}
/**
* Закрывает модальное окно импорта
*/
function closeImportModal() {
const modal = document.getElementById('import-task-modal');
if (modal) {
modal.style.display = 'none';
}
importTaskData = null;
}
/**
* Выполняет импорт задачи: создаёт локальную задачу с выбранными исполнителями
*/
async function confirmImport() {
if (!importTaskData) {
showAlert('Нет данных для импорта', 'danger');
return;
}
const dueDate = document.getElementById('import-due-date').value;
if (!dueDate) {
showAlert('Укажите дату выполнения', 'warning');
return;
}
// Собираем выбранных исполнителей
const checkboxes = document.querySelectorAll('.import-user-checkbox:checked');
const assignedUsers = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (assignedUsers.length === 0) {
showAlert('Выберите хотя бы одного исполнителя', 'warning');
return;
}
// Формируем FormData для отправки
const formData = new FormData();
formData.append('title', importTaskData.title);
formData.append('description', importTaskData.description);
formData.append('dueDate', dueDate);
formData.append('taskType', importTaskData.task_type);
formData.append('assignedUsers', JSON.stringify(assignedUsers));
console.log('FormData assignedUsers:', assignedUsers);
// Если есть файлы добавляем их (но тут сложность: файлы нужно скачать из внешнего сервиса и загрузить)
// Пока пропустим файлы для простоты, можно добавить позже.
try {
const response = await fetch('/api/tasks', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showAlert(`Задача успешно импортирована! Новый ID: ${result.taskId}`, 'success');
closeImportModal();
// Можно перезагрузить список локальных задач, если нужно
} else {
showAlert('Ошибка создания задачи: ' + (result.error || 'Неизвестная ошибка'), 'danger');
}
} catch (error) {
console.error('Ошибка импорта:', error);
showAlert('Ошибка при создании задачи', 'danger');
}
}
// ==================== РАБОТА С ФАЙЛАМИ ====================
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');
}
}
// ==================== ПАГИНАЦИЯ ====================
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();
});