1791 lines
81 KiB
JavaScript
1791 lines
81 KiB
JavaScript
// ui.js - UI функции и рендеринг
|
||
|
||
function showSection(sectionName) {
|
||
document.querySelectorAll('.section').forEach(section => {
|
||
section.classList.remove('active');
|
||
});
|
||
|
||
document.getElementById(sectionName + '-section').classList.add('active');
|
||
|
||
if (sectionName === 'tasks') {
|
||
loadTasks();
|
||
} else if (sectionName === 'logs') {
|
||
loadActivityLogs();
|
||
} else if (sectionName === 'kanban') {
|
||
loadKanbanTasks();
|
||
}
|
||
|
||
// Загрузка профиля при переходе в личный кабинет
|
||
if (sectionName === 'profile') {
|
||
loadUserProfile();
|
||
loadNotificationSettings();
|
||
}
|
||
}
|
||
|
||
function renderTasks() {
|
||
const container = document.getElementById('tasks-list');
|
||
const showDeleted = document.getElementById('show-deleted')?.checked || false;
|
||
|
||
let filteredTasks = tasks;
|
||
if (!showDeleted) {
|
||
filteredTasks = tasks.filter(task => task.status === 'active');
|
||
}
|
||
|
||
if (filteredTasks.length === 0) {
|
||
container.innerHTML = '<div class="loading">Задачи не найдены</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = filteredTasks.map(task => {
|
||
const isExpanded = expandedTasks.has(task.id);
|
||
const overallStatus = getTaskOverallStatus(task);
|
||
const statusClass = getStatusClass(overallStatus);
|
||
const isDeleted = task.status === 'deleted';
|
||
const isClosed = task.closed_at !== null;
|
||
const userRole = getUserRoleInTask(task);
|
||
const canEdit = canUserEditTask(task);
|
||
const isCopy = task.original_task_id !== null;
|
||
|
||
const timeLeftInfo = getTimeLeftInfo(task);
|
||
|
||
return `
|
||
<div class="task-card ${isDeleted ? 'deleted' : ''} ${isClosed ? 'closed' : ''}" data-task-id="${task.id}">
|
||
<div class="task-header">
|
||
<div class="task-title" onclick="toggleTask(${task.id})" style="cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
|
||
<div style="flex: 1;">
|
||
<!--
|
||
${task.task_type ? `<span class="task-type-badge ${task.task_type}">${getTaskTypeDisplayName(task.task_type)}</span>` : ''}
|
||
-->
|
||
<span class="task-number">Задача №${task.id}</span>
|
||
<strong>${task.title}</strong>
|
||
${isDeleted ? '<span class="deleted-badge">Удалена</span>' : ''}
|
||
${isClosed ? '<span class="closed-badge">Закрыта</span>' : ''}
|
||
${isCopy ? '<span class="copy-badge">Копия</span>' : ''}
|
||
${timeLeftInfo ? `<span class="deadline-badge ${timeLeftInfo.class}">${timeLeftInfo.text}</span>` : ''}
|
||
<span class="role-badge ${getRoleBadgeClass(userRole)}">${userRole}</span>
|
||
${task.assignments && task.assignments.length > 0 ? `<span class="task-number">${task.assignments.map(a => a.user_login || a.user_name).join(', ')}</span>` : ''}
|
||
</div>
|
||
<!--
|
||
<div class="task-status ${statusClass}">
|
||
${getStatusText(overallStatus)}
|
||
</div>
|
||
-->
|
||
<span class="task-status ${statusClass}">
|
||
Выполнить до: ${formatDateTime(task.due_date || task.created_at)}
|
||
</span>
|
||
<div class="expand-icon" style="margin-left: 10px; transition: transform 0.3s; transform: rotate(${isExpanded ? '180deg' : '0deg'});">
|
||
▼
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-content ${isExpanded ? 'expanded' : ''}">
|
||
${isExpanded ? `
|
||
<div class="task-actions">
|
||
${!isDeleted && !isClosed ? `
|
||
<button class="copy-btn" onclick="openTaskChat(${task.id})" title="Открыть чат">💬</button>
|
||
<button class="add-file-btn" onclick="openAddFileModal(${task.id})" title="Добавить файл">📎</button>
|
||
${currentUser && currentUser.login === 'minicrm' ? `<button class="edit-btn" onclick="openEditModal(${task.id})" title="Редактировать">✏️</button>` : ''}
|
||
${currentUser && currentUser.login === 'kalugin.o' ? `<button class="manage-assignees-btn" onclick="openManageAssigneesModal(${task.id})" title="Управление исполнителями">👥</button>` : ''}
|
||
${currentUser && currentUser.role === 'tasks' && canEdit || currentUser.role === 'admin' ? `<button class="manage-assignees-btn" onclick="assignAdd_openModal(${task.id})" title="Управление исполнителями">🧑💼➕Добавить</button>` : ''}
|
||
${currentUser && currentUser.role === 'tasks' && canEdit || currentUser.role === 'admin' ? `<button class="manage-assignees-btn" onclick="assignRemove_openModal(${task.id})" title="Управление исполнителями">🧑💼❌Удалить</button>` : ''}
|
||
<button class="copy-btn" onclick="openCopyModal(${task.id})" title="Создать копию">📋</button>
|
||
${currentUser && currentUser.login === 'minicrm' ? `<button class="rework-btn" onclick="openReworkModal(${task.id})" title="Вернуть на доработку">🔄</button>` : ''}
|
||
${currentUser && currentUser.login === 'minicrm' ? `<button class="close-btn" onclick="closeTask(${task.id})" title="Закрыть задачу">🔒</button>` : ''}
|
||
${canEdit ? `<button class="delete-btn" onclick="deleteTask(${task.id})" title="Удалить">🗑️</button>` : ''}
|
||
` : ''}
|
||
${isClosed && canEdit ? `
|
||
<button class="reopen-btn" onclick="reopenTask(${task.id})" title="Открыть задачу">🔓</button>
|
||
` : ''}
|
||
${isDeleted && currentUser.role === 'admin' ? `
|
||
<button class="restore-btn" onclick="restoreTask(${task.id})" title="Восстановить">↶</button>
|
||
` : ''}
|
||
</div>
|
||
` : ''}
|
||
|
||
${isCopy && task.original_task_title ? `
|
||
<div class="task-original">
|
||
<small>Оригинал: "${task.original_task_title}" (создал: ${task.original_creator_name})</small>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="task-description">${task.description || 'Нет описания'}</div>
|
||
|
||
${task.rework_comment ? `
|
||
<div class="rework-comment">
|
||
<strong>Комментарий к доработке:</strong> ${task.rework_comment}
|
||
</div>
|
||
` : ''}
|
||
|
||
|
||
<div class="file-list" id="files-${task.id}">
|
||
<strong>Файлы:</strong>
|
||
${task.files && task.files.length > 0 ? renderGroupedFilesWithDelete(task) : '<span class="no-files">нет файлов</span>'}
|
||
</div>
|
||
|
||
|
||
<div class="task-assignments">
|
||
<strong>Исполнители:</strong>
|
||
${task.assignments && task.assignments.length > 0 ?
|
||
renderAssignmentList(task.assignments, task.id, canEdit) :
|
||
'<div>Не назначены</div>'
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-meta">
|
||
<small>
|
||
Создана: ${formatDateTime(task.start_date || task.created_at)}
|
||
| Выполнить до: ${formatDateTime(task.due_date || task.created_at)}
|
||
| Автор: ${task.creator_name}
|
||
| Тип: ${task.task_type ? `<span class="task-type-badge ${task.task_type}">${getTaskTypeDisplayName(task.task_type)}</span>` : ''}
|
||
</small>
|
||
${task.deleted_at ? `<br><small>Удалена: ${formatDateTime(task.deleted_at)}</small>` : ''}
|
||
${task.closed_at ? `<br><small>Закрыта: ${formatDateTime(task.closed_at)}</small>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
// Функция для рендеринга списка исполнителей с фильтрацией
|
||
function renderAssignmentList(assignments, taskId, canEdit) {
|
||
if (!assignments || assignments.length === 0) {
|
||
return '<div>Не назначены</div>';
|
||
}
|
||
|
||
// Создаем контейнер с возможностью фильтрации
|
||
return `
|
||
<div class="assignments-container">
|
||
<div class="assignments-filter">
|
||
<input type="text"
|
||
class="assignment-filter-input"
|
||
placeholder="Поиск исполнителя..."
|
||
data-task-id="${taskId}"
|
||
oninput="filterAssignments(${taskId})">
|
||
<span class="filter-count" id="filter-count-${taskId}">${assignments.length} исполнителей</span>
|
||
</div>
|
||
<div class="assignments-scroll-container" id="assignments-${taskId}">
|
||
${assignments.map(assignment => renderAssignment(assignment, taskId, canEdit)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Функция для открытия модального окна доработки для конкретного исполнителя
|
||
function openReworkAssignmentModal(taskId, userId, userName) {
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
alert('Задача не найдена');
|
||
return;
|
||
}
|
||
|
||
// Проверяем права (только автор задачи или администратор)
|
||
if (parseInt(task.created_by) !== currentUser.id && currentUser.role !== 'admin') {
|
||
alert('Только автор задачи может отправлять на доработку');
|
||
return;
|
||
}
|
||
|
||
// Удаляем предыдущее модальное окно, если оно есть
|
||
const existingModal = document.getElementById('rework-assignment-modal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// Создаем модальное окно
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal';
|
||
modal.id = 'rework-assignment-modal';
|
||
modal.style.display = 'block';
|
||
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Отправить на доработку</h3>
|
||
<span class="close" onclick="closeReworkAssignmentModal()">×</span>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p><strong>Задача:</strong> ${escapeHtml(task.title)}</p>
|
||
<p><strong>Исполнитель:</strong> ${escapeHtml(userName)}</p>
|
||
<div class="form-group">
|
||
<label for="rework-comment-${taskId}-${userId}"><strong>Комментарий к доработке:</strong></label>
|
||
<textarea id="rework-comment-${taskId}-${userId}"
|
||
class="form-control"
|
||
rows="5"
|
||
placeholder="Опишите, что нужно доработать..."></textarea>
|
||
</div>
|
||
<div class="modal-footer" style="margin-top: 20px; text-align: right;">
|
||
<button type="button" class="btn-rework-cancel" onclick="closeReworkAssignmentModal()">Отмена</button>
|
||
<button type="button" class="btn-rework-primary" onclick="submitReworkAssignment('${taskId}', '${userId}')">🔄 Отправить на доработку</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Фокусируемся на textarea
|
||
const textarea = document.getElementById(`rework-comment-${taskId}-${userId}`);
|
||
if (textarea) {
|
||
setTimeout(() => textarea.focus(), 100);
|
||
}
|
||
}
|
||
// Функция для принудительного завершения задачи исполнителя
|
||
async function forceCompleteAssignment(taskId, userId, userName) {
|
||
if (!confirm(`Вы уверены, что хотите отметить задачу как выполненную для сотрудника ${userName}?\nЭто действие нельзя отменить.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/force-complete/${userId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
alert(`✅ Задача отмечена как выполненная для сотрудника ${userName}`);
|
||
loadTasks(); // Перезагружаем задачи
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`❌ Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Ошибка:', error);
|
||
alert('Сетевая ошибка при выполнении операции');
|
||
}
|
||
}
|
||
// Вспомогательная функция для экранирования HTML
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
return String(text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// Функция для отправки на доработку конкретного исполнителя
|
||
async function submitReworkAssignment(taskId, userId) {
|
||
// Получаем textarea по уникальному ID
|
||
const textarea = document.getElementById(`rework-comment-${taskId}-${userId}`);
|
||
|
||
if (!textarea) {
|
||
alert('Ошибка: поле комментария не найдено');
|
||
return;
|
||
}
|
||
|
||
const comment = textarea.value.trim();
|
||
|
||
// Детальная отладка
|
||
console.log('=== ОТПРАВКА НА ДОРАБОТКУ ===');
|
||
console.log('Task ID:', taskId);
|
||
console.log('User ID:', userId);
|
||
console.log('Comment length:', comment.length);
|
||
console.log('Comment text:', comment);
|
||
console.log('============================');
|
||
|
||
if (!comment) {
|
||
alert('Пожалуйста, укажите комментарий к доработке');
|
||
textarea.style.border = '2px solid red';
|
||
textarea.focus();
|
||
return;
|
||
}
|
||
|
||
// Блокируем кнопку отправки
|
||
const submitBtn = document.querySelector(`#rework-assignment-modal .btn-warning`);
|
||
if (submitBtn) {
|
||
submitBtn.disabled = true;
|
||
submitBtn.innerHTML = '⏳ Отправка...';
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/rework-assignment/${userId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
comment: comment // Отправляем явно
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok) {
|
||
alert('✅ Исполнитель отправлен на доработку');
|
||
closeReworkAssignmentModal();
|
||
// Перезагружаем задачи
|
||
if (typeof loadTasks === 'function') {
|
||
loadTasks();
|
||
} else {
|
||
location.reload();
|
||
}
|
||
} else {
|
||
alert(`❌ Ошибка: ${data.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('❌ Ошибка:', error);
|
||
alert('Сетевая ошибка при отправке на доработку: ' + error.message);
|
||
} finally {
|
||
// Разблокируем кнопку
|
||
if (submitBtn) {
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = '🔄 Отправить на доработку';
|
||
}
|
||
}
|
||
}
|
||
// Функция для закрытия модального окна доработки
|
||
function closeReworkAssignmentModal() {
|
||
const modal = document.getElementById('rework-assignment-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
setTimeout(() => {
|
||
modal.remove();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// Функция для фильтрации исполнителей в конкретной задаче
|
||
function filterAssignments(taskId) {
|
||
const filterInput = document.querySelector(`.assignment-filter-input[data-task-id="${taskId}"]`);
|
||
const scrollContainer = document.getElementById(`assignments-${taskId}`);
|
||
const filterCount = document.getElementById(`filter-count-${taskId}`);
|
||
|
||
if (!filterInput || !scrollContainer) return;
|
||
|
||
const searchTerm = filterInput.value.toLowerCase();
|
||
const assignments = scrollContainer.querySelectorAll('.assignment');
|
||
|
||
let visibleCount = 0;
|
||
|
||
assignments.forEach(assignment => {
|
||
const userName = assignment.querySelector('strong')?.textContent?.toLowerCase() || '';
|
||
const userLogin = assignment.querySelector('small')?.textContent?.toLowerCase() || '';
|
||
|
||
const isVisible = userName.includes(searchTerm) ||
|
||
userLogin.includes(searchTerm) ||
|
||
searchTerm === '';
|
||
|
||
assignment.style.display = isVisible ? '' : 'none';
|
||
|
||
if (isVisible) {
|
||
visibleCount++;
|
||
}
|
||
});
|
||
|
||
if (filterCount) {
|
||
filterCount.textContent = `${visibleCount} из ${assignments.length} исполнителей`;
|
||
}
|
||
}
|
||
|
||
function toggleTask(taskId) {
|
||
if (expandedTasks.has(taskId)) {
|
||
expandedTasks.delete(taskId);
|
||
} else {
|
||
expandedTasks.add(taskId);
|
||
loadTaskFiles(taskId);
|
||
}
|
||
renderTasks();
|
||
}
|
||
|
||
function getTimeLeftInfo(task) {
|
||
if (!task.due_date || task.closed_at) return null;
|
||
|
||
const dueDate = new Date(task.due_date);
|
||
const now = new Date();
|
||
const timeLeft = dueDate.getTime() - now.getTime();
|
||
const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000));
|
||
|
||
if (hoursLeft <= 0) return null;
|
||
|
||
if (hoursLeft <= 24) {
|
||
return {
|
||
text: `Менее 24ч`,
|
||
class: 'deadline-24h'
|
||
};
|
||
} else if (hoursLeft <= 48) {
|
||
return {
|
||
text: `Менее 48ч`,
|
||
class: 'deadline-48h'
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Функция для рендеринга одного исполнителя
|
||
function renderAssignment(assignment, taskId, canEdit) {
|
||
const statusClass = getStatusClass(assignment.status);
|
||
const isCurrentUser = assignment.user_id === currentUser.id;
|
||
const isOverdue = assignment.status === 'overdue';
|
||
const isRework = assignment.status === 'rework';
|
||
|
||
const timeLeftInfo = getAssignmentTimeLeftInfo(assignment);
|
||
|
||
// Проверяем, является ли текущий пользователь автором задачи
|
||
const task = tasks.find(t => t.id === taskId);
|
||
const isTaskCreator = task && parseInt(task.created_by) === currentUser.id;
|
||
|
||
return `
|
||
<div class="assignment ${isOverdue ? 'overdue' : ''} ${isRework ? 'rework' : ''}">
|
||
<span class="assignment-status ${statusClass}"></span>
|
||
<div style="flex: 1;">
|
||
<strong>${assignment.user_name}</strong>
|
||
${isCurrentUser ? '<small>(Вы)</small>' : ''}
|
||
${timeLeftInfo ? `<span class="deadline-indicator ${timeLeftInfo.class}">${timeLeftInfo.text}</span>` : ''}
|
||
${assignment.start_date || assignment.due_date ? `
|
||
<div class="assignment-dates">
|
||
${assignment.start_date ? `<small>Начало: ${formatDateTime(assignment.start_date)}</small>` : ''}
|
||
${assignment.due_date ? `<small>Выполнить до: ${formatDateTime(assignment.due_date)}</small>` : ''}
|
||
</div>
|
||
` : ''}
|
||
${assignment.rework_comment ? `
|
||
<div class="assignment-rework-comment">
|
||
<small><strong>Комментарий:</strong> ${assignment.rework_comment}</small>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="action-buttons">
|
||
${isCurrentUser && assignment.status === 'assigned' ?
|
||
`<button onclick="updateStatus(${taskId}, ${assignment.user_id}, 'in_progress')">Приступить</button>` : ''}
|
||
${isCurrentUser && (assignment.status === 'in_progress' || assignment.status === 'overdue' || assignment.status === 'rework') ?
|
||
`<button onclick="updateStatus(${taskId}, ${assignment.user_id}, 'completed')">Выполнено</button>` : ''}
|
||
${isTaskCreator && assignment.status !== 'assigned' ?
|
||
`<button class="rework-btn" onclick="openReworkAssignmentModal(${taskId}, ${assignment.user_id}, '${assignment.user_name}')" title="Отправить на доработку">🔄Переделать</button>` : ''}
|
||
${isTaskCreator && assignment.status !== 'completed' ?
|
||
`<button class="force-complete-btn" onclick="forceCompleteAssignment(${taskId}, ${assignment.user_id}, '${assignment.user_name}')" title="Принудительно отметить как выполненное">✅ Завершить</button>` : ''}
|
||
${canEdit ?
|
||
`<button class="edit-date-btn" onclick="openEditAssignmentModal(${taskId}, ${assignment.user_id})" title="Редактировать сроки">📅 </button>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function getAssignmentTimeLeftInfo(assignment) {
|
||
if (!assignment.due_date || assignment.status === 'completed') return null;
|
||
|
||
const dueDate = new Date(assignment.due_date);
|
||
const now = new Date();
|
||
const timeLeft = dueDate.getTime() - now.getTime();
|
||
const hoursLeft = Math.floor(timeLeft / (60 * 60 * 1000));
|
||
|
||
if (hoursLeft <= 0) return null;
|
||
|
||
if (hoursLeft <= 24) {
|
||
return {
|
||
text: `Осталось ${hoursLeft}ч`,
|
||
class: 'deadline-24h'
|
||
};
|
||
} else if (hoursLeft <= 48) {
|
||
return {
|
||
text: `Осталось ${hoursLeft}ч`,
|
||
class: 'deadline-48h'
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getTaskOverallStatus(task) {
|
||
if (task.status === 'deleted') return 'deleted';
|
||
if (task.closed_at) return 'closed';
|
||
if (!task.assignments || task.assignments.length === 0) return 'unassigned';
|
||
|
||
const assignments = task.assignments;
|
||
let hasAssigned = false;
|
||
let hasInProgress = false;
|
||
let hasOverdue = false;
|
||
let hasRework = false;
|
||
let allCompleted = true;
|
||
|
||
for (let assignment of assignments) {
|
||
if (assignment.status === 'assigned') {
|
||
hasAssigned = true;
|
||
allCompleted = false;
|
||
} else if (assignment.status === 'in_progress') {
|
||
hasInProgress = true;
|
||
allCompleted = false;
|
||
} else if (assignment.status === 'overdue') {
|
||
hasOverdue = true;
|
||
allCompleted = false;
|
||
} else if (assignment.status === 'rework') {
|
||
hasRework = true;
|
||
allCompleted = false;
|
||
} else if (assignment.status !== 'completed') {
|
||
allCompleted = false;
|
||
}
|
||
}
|
||
|
||
if (allCompleted) return 'completed';
|
||
if (hasRework) return 'rework';
|
||
if (hasOverdue) return 'overdue';
|
||
if (hasInProgress) return 'in_progress';
|
||
if (hasAssigned) return 'assigned';
|
||
return 'unassigned';
|
||
}
|
||
|
||
function getStatusClass(status) {
|
||
switch (status) {
|
||
case 'deleted': return 'status-gray';
|
||
case 'closed': return 'status-gray';
|
||
case 'unassigned': return 'status-purple';
|
||
case 'assigned': return 'status-red';
|
||
case 'in_progress': return 'status-orange';
|
||
case 'rework': return 'status-yellow';
|
||
case 'overdue': return 'status-darkred';
|
||
case 'completed': return 'status-green';
|
||
default: return 'status-purple';
|
||
}
|
||
}
|
||
|
||
function getStatusText(status) {
|
||
switch (status) {
|
||
case 'deleted': return 'Удалена';
|
||
case 'closed': return 'Закрыта';
|
||
case 'unassigned': return 'Не назначена';
|
||
case 'assigned': return 'Назначена';
|
||
case 'in_progress': return 'В работе';
|
||
case 'rework': return 'На доработке';
|
||
case 'overdue': return 'Просрочена';
|
||
case 'completed': return 'Выполнена';
|
||
default: return 'Неизвестно';
|
||
}
|
||
}
|
||
|
||
function getUserRoleInTask(task) {
|
||
if (!currentUser) return 'Нет доступа';
|
||
|
||
if (currentUser.role === 'admin') return 'Администратор';
|
||
|
||
// Создатель задачи всегда должен иметь право редактировать свою задачу
|
||
if (parseInt(task.created_by) === currentUser.id) {
|
||
return 'Создатель';
|
||
}
|
||
|
||
if (task.assignments) {
|
||
const isExecutor = task.assignments.some(assignment =>
|
||
parseInt(assignment.user_id) === currentUser.id
|
||
);
|
||
if (isExecutor) return 'Исполнитель';
|
||
}
|
||
|
||
return 'Наблюдатель';
|
||
}
|
||
|
||
function getRoleBadgeClass(role) {
|
||
switch (role) {
|
||
case 'Администратор': return 'role-admin';
|
||
case 'Заказчик': return 'role-creator';
|
||
case 'Исполнитель': return 'role-executor';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
function formatDateTime(dateTimeString) {
|
||
if (!dateTimeString) return '';
|
||
const date = new Date(dateTimeString);
|
||
return date.toLocaleString('ru-RU');
|
||
}
|
||
|
||
function formatDateTimeForInput(dateTimeString) {
|
||
if (!dateTimeString) return '';
|
||
const date = new Date(dateTimeString);
|
||
return date.toISOString().slice(0, 16);
|
||
}
|
||
|
||
// Логи активности
|
||
async function loadActivityLogs() {
|
||
try {
|
||
const response = await fetch('/api/activity-logs');
|
||
const logs = await response.json();
|
||
renderLogs(logs);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки логов:', error);
|
||
}
|
||
}
|
||
|
||
function renderLogs(logs) {
|
||
const container = document.getElementById('logs-list');
|
||
|
||
if (logs.length === 0) {
|
||
container.innerHTML = '<div class="loading">Логи не найдены</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = logs.map(log => `
|
||
<div class="log-entry">
|
||
<div class="log-time">${formatDateTime(log.created_at)}</div>
|
||
<div><strong>${log.user_name}</strong> - ${getActionText(log.action)}</div>
|
||
<div>Задача: "${log.task_title}"</div>
|
||
${log.details ? `<div>Детали: ${log.details}</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function getActionText(action) {
|
||
const actions = {
|
||
'TASK_CREATED': 'создал задачу',
|
||
'TASK_COPIED': 'создал копию задачи',
|
||
'TASK_UPDATED': 'обновил задачу',
|
||
'TASK_DELETED': 'удалил задачу',
|
||
'TASK_RESTORED': 'восстановил задачу',
|
||
'TASK_ASSIGNED': 'назначил задачу',
|
||
'TASK_ASSIGNMENTS_UPDATED': 'обновил назначения',
|
||
'ASSIGNMENT_UPDATED': 'обновил сроки исполнителя',
|
||
'STATUS_CHANGED': 'изменил статус задачи',
|
||
'FILE_UPLOADED': 'загрузил файл',
|
||
'FILE_COPIED': 'скопировал файл',
|
||
'TASK_SENT_FOR_REWORK': 'вернул задачу на доработку',
|
||
'TASK_CLOSED': 'закрыл задачу',
|
||
'TASK_REOPENED': 'открыл задачу'
|
||
};
|
||
|
||
return actions[action] || action;
|
||
}
|
||
|
||
// Функция для просмотра деталей задачи
|
||
async function viewTaskDetails(taskId) {
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}`);
|
||
const task = await response.json();
|
||
|
||
// Можно открыть модальное окно с подробной информацией
|
||
// Или показать в отдельной секции
|
||
alert(`Задача: ${task.title}\n\nОписание: ${task.description || 'Нет описания'}\n\nСоздатель: ${task.creator_name}\nСрок: ${task.due_date ? new Date(task.due_date).toLocaleString('ru-RU') : 'Не установлен'}`);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки деталей задачи:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для открытия модального окна добавления файла
|
||
function openAddFileModal(taskId) {
|
||
// Находим задачу
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
alert('Задача не найдена');
|
||
return;
|
||
}
|
||
|
||
// Проверяем права
|
||
if (!canUserAddFilesToTask(task)) {
|
||
alert('У вас нет прав для добавления файлов к этой задаче');
|
||
return;
|
||
}
|
||
|
||
// Создаем модальное окно
|
||
const modalHtml = `
|
||
<div class="modal" id="add-file-modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Добавить файл к задаче: "${task.title}"</h3>
|
||
<span class="close" onclick="closeAddFileModal()">×</span>
|
||
</div>
|
||
<div class="modal-body">
|
||
<form id="add-file-form">
|
||
<input type="hidden" name="task_id" value="${taskId}">
|
||
|
||
<div class="form-group">
|
||
<label for="file-input">Выберите файл:</label>
|
||
<input type="file" id="file-input" name="files" multiple>
|
||
<small>Максимальный размер: 10MB</small>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="file-description">Описание файла (необязательно):</label>
|
||
<textarea id="file-description" name="description" rows="3"
|
||
placeholder="Описание файла..."></textarea>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeAddFileModal()">Отмена</button>
|
||
<button type="submit" class="btn-primary">Добавить файл</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавляем модальное окно в DOM
|
||
const modalContainer = document.createElement('div');
|
||
modalContainer.innerHTML = modalHtml;
|
||
document.body.appendChild(modalContainer);
|
||
|
||
// Обработчик отправки формы
|
||
document.getElementById('add-file-form').addEventListener('submit', async function(e) {
|
||
e.preventDefault();
|
||
|
||
const fileInput = document.getElementById('file-input');
|
||
const description = document.getElementById('file-description').value;
|
||
|
||
if (fileInput.files.length === 0) {
|
||
alert('Выберите файл для загрузки');
|
||
return;
|
||
}
|
||
|
||
const file = fileInput.files[0];
|
||
const formData = new FormData();
|
||
|
||
// Пробуем сначала 'files', затем 'file'
|
||
formData.append('files', file); // Или 'file' в зависимости от сервера
|
||
formData.append('task_id', taskId);
|
||
if (description) {
|
||
formData.append('description', description);
|
||
}
|
||
|
||
try {
|
||
// Попробуем с полем 'files' (скорее всего это правильное имя)
|
||
let response = await fetch(`/api/tasks/${taskId}/files`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
console.log('Попытка 1');
|
||
|
||
if (!response.ok) {
|
||
// Попробуем с полем 'file'
|
||
console.log('Попытка 2 с именем поля "file"...');
|
||
formData.delete('files');
|
||
formData.append('file', file);
|
||
|
||
response = await fetch(`/api/tasks/${taskId}/files`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
}
|
||
|
||
if (response.ok) {
|
||
alert('Файл успешно добавлен');
|
||
await loadTaskFiles(taskId);
|
||
closeAddFileModal();
|
||
|
||
// Обновляем задачу, если она развернута
|
||
if (expandedTasks.has(taskId)) {
|
||
renderTasks();
|
||
}
|
||
} else {
|
||
const errorText = await response.text();
|
||
console.error('Ошибка сервера:', errorText);
|
||
alert(`Ошибка при добавлении файла: ${response.status} ${response.statusText}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка:', error);
|
||
alert('Сетевая ошибка при добавлении файла: ' + error.message);
|
||
}
|
||
});
|
||
|
||
// Показываем модальное окно
|
||
setTimeout(() => {
|
||
document.getElementById('add-file-modal').style.display = 'block';
|
||
}, 10);
|
||
}
|
||
|
||
// Функция для закрытия модального окна добавления файла
|
||
function closeAddFileModal() {
|
||
const modal = document.getElementById('add-file-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
// Удаляем модальное окно из DOM через некоторое время
|
||
setTimeout(() => {
|
||
modal.parentElement.remove();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// Функция для закрытия модального окна добавления файла
|
||
function closeAddFileModal() {
|
||
const modal = document.getElementById('add-file-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
// Удаляем модальное окно из DOM через некоторое время
|
||
setTimeout(() => {
|
||
modal.parentElement.remove();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// Обновленная функция для загрузки файлов задачи
|
||
async function loadTaskFiles(taskId) {
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/files`);
|
||
const allFiles = await response.json();
|
||
|
||
const taskIndex = tasks.findIndex(t => t.id === taskId);
|
||
if (taskIndex === -1) {
|
||
console.error('Задача не найдена:', taskId);
|
||
return;
|
||
}
|
||
|
||
tasks[taskIndex].files = allFiles;
|
||
|
||
const fileContainer = document.getElementById(`files-${taskId}`);
|
||
if (fileContainer) {
|
||
fileContainer.innerHTML = `
|
||
<strong>Файлы:</strong>
|
||
${allFiles.length > 0 ?
|
||
(typeof renderGroupedFilesWithDelete === 'function' ?
|
||
renderGroupedFilesWithDelete(tasks[taskIndex]) :
|
||
renderGroupedFiles(tasks[taskIndex])) :
|
||
'<span class="no-files">нет файлов</span>'}
|
||
`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки файлов:', error);
|
||
}
|
||
}
|
||
|
||
// Функция для выбора типа задачи
|
||
async function selectTaskType(type) {
|
||
// Убираем активный класс со всех кнопок
|
||
document.querySelectorAll('.task-type-btn').forEach(btn => {
|
||
btn.classList.remove('active');
|
||
});
|
||
|
||
// Добавляем активный класс выбранной кнопке
|
||
document.querySelector(`.task-type-btn[data-type="${type}"]`).classList.add('active');
|
||
|
||
// Устанавливаем значение в скрытое поле
|
||
document.getElementById('task-type').value = type;
|
||
|
||
// Можем добавить дополнительную логику в зависимости от типа
|
||
updateTaskFormBasedOnType(type);
|
||
// Предлагаем заголовок по умолчанию
|
||
suggestDefaultTitle(type);
|
||
|
||
// Для типа "document" перезагружаем пользователей с фильтрацией
|
||
if (type === 'document') {
|
||
await reloadUsersForDocumentType();
|
||
} else if (type === 'it') {
|
||
await reloadUsersForType('ИТ специалист');
|
||
} else if (type === 'ahch') {
|
||
await reloadUsersForType('АХЧ');
|
||
} else if (type === 'psychologist') {
|
||
await reloadUsersForType('психолог');
|
||
} else if (type === 'speech_therapist') {
|
||
await reloadUsersForType('логопед');
|
||
} else if (type === 'hr') {
|
||
await reloadUsersForType('кадровичка');
|
||
} else if (type === 'certificate') {
|
||
await reloadUsersForType('Администрация');
|
||
} else if (type === 'e_journal') {
|
||
await reloadUsersForType('Админ ЭЖ');
|
||
} else {
|
||
// Для других типов перезагружаем обычных пользователей
|
||
await loadUsers();
|
||
}
|
||
}
|
||
// функция для перезагрузки пользователей для типа "document"
|
||
async function reloadUsersForDocumentType() {
|
||
try {
|
||
const response = await fetch('/api/users');
|
||
const allUsersData = await response.json();
|
||
|
||
// Фильтруем только пользователей из группы "Секретарь"
|
||
const secretaries = [];
|
||
for (const user of allUsersData) {
|
||
if (user.id === currentUser.id) continue;
|
||
|
||
const groups = await getUserGroups(user.id);
|
||
const hasSecretaryGroup = groups.some(group =>
|
||
group.name === 'Секретарь' ||
|
||
(typeof group === 'string' && group.includes('Секретарь'))
|
||
);
|
||
|
||
if (hasSecretaryGroup) {
|
||
secretaries.push(user);
|
||
}
|
||
}
|
||
|
||
users = secretaries;
|
||
filteredUsers = [...users];
|
||
renderUsersChecklist();
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки секретарей:', error);
|
||
}
|
||
}
|
||
// функция для перезагрузки пользователей для типа "в переменной"
|
||
async function reloadUsersForType(TypegroupName) {
|
||
try {
|
||
const response = await fetch('/api/users');
|
||
const allUsersData = await response.json();
|
||
|
||
const TypegroupUsers = [];
|
||
for (const user of allUsersData) {
|
||
if (user.id === currentUser.id) continue;
|
||
|
||
const groups = await getUserGroups(user.id);
|
||
const hasTargetGroup = groups.some(group =>
|
||
group.name === TypegroupName ||
|
||
(typeof group === 'string' && group.includes(TypegroupName))
|
||
);
|
||
|
||
if (hasTargetGroup) {
|
||
TypegroupUsers.push(user);
|
||
}
|
||
}
|
||
|
||
users = TypegroupUsers;
|
||
filteredUsers = [...users];
|
||
renderUsersChecklist();
|
||
|
||
return filteredUsers;
|
||
|
||
} catch (error) {
|
||
console.error(`Ошибка загрузки пользователей группы "${TypegroupName}":`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
// Используем кэширование, чтобы не делать лишние запросы
|
||
async function getUserGroups(userId) {
|
||
// Используем кэширование, чтобы не делать лишние запросы
|
||
if (!window.userGroupsCache) window.userGroupsCache = {};
|
||
|
||
if (window.userGroupsCache[userId]) {
|
||
return window.userGroupsCache[userId];
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api2/idusers/user/${userId}/groups`);
|
||
if (!response.ok) return [];
|
||
|
||
const groups = await response.json();
|
||
window.userGroupsCache[userId] = groups || [];
|
||
return window.userGroupsCache[userId];
|
||
} catch (error) {
|
||
console.error('Ошибка получения групп пользователя:', error);
|
||
return [];
|
||
}
|
||
}
|
||
// Функция для предложения заголовка по умолчанию
|
||
function suggestDefaultTitle(type) {
|
||
const titleField = document.getElementById('title');
|
||
if (!titleField) return;
|
||
|
||
// Если поле уже заполнено, не меняем
|
||
if (titleField.value.trim() !== '') return;
|
||
|
||
const defaultTitles = {
|
||
'regular': '',
|
||
'document': 'Согласование документа: ',
|
||
'it': 'Заявка в ИТ отдел: ',
|
||
'ahch': 'Заявка в АХЧ: ',
|
||
'psychologist': 'Заявка к психологу: ',
|
||
'speech_therapist': 'Заявка к логопеду: ',
|
||
'hr': 'Заявка в кадры: ',
|
||
'certificate': 'Заявка на получение справки: ',
|
||
'e_journal': 'Заявка на доступ в электронный журнал: '
|
||
};
|
||
|
||
const defaultTitle = defaultTitles[type] || '';
|
||
titleField.placeholder = `${defaultTitle}укажите детали...`;
|
||
}
|
||
|
||
// Функция для обновления формы в зависимости от типа задачи
|
||
function updateTaskFormBasedOnType(type) {
|
||
const userSearchField = document.getElementById('user-search');
|
||
const taskTypeInfo = document.getElementById('task-type-info');
|
||
|
||
// Устанавливаем заголовки placeholder для поиска исполнителей
|
||
const defaultPlaceholders = {
|
||
'regular': 'Поиск исполнителей...',
|
||
'document': 'Поиск секретарей/администрации...',
|
||
'it': 'Поиск ИТ специалистов...',
|
||
'ahch': 'Поиск АХЧ сотрудников...',
|
||
'psychologist': 'Поиск психологов...',
|
||
'speech_therapist': 'Поиск логопедов...',
|
||
'hr': 'Поиск сотрудников кадровой службы...',
|
||
'certificate': 'Поиск секретаря/завуча...',
|
||
'e_journal': 'Поиск администратора электронного журнала...'
|
||
};
|
||
|
||
// Обновляем placeholder поля поиска исполнителей
|
||
if (userSearchField) {
|
||
userSearchField.placeholder = defaultPlaceholders[type] || 'Поиск исполнителей...';
|
||
}
|
||
// Показываем информацию о типе задачи
|
||
if (taskTypeInfo) {
|
||
const typeInfo = {
|
||
'regular': 'Доступны все пользователи в зависимости от ваших прав',
|
||
'document': 'Доступны только пользователи из группы "Секретарь"',
|
||
'it': 'Доступны ИТ специалисты',
|
||
'ahch': 'Доступны сотрудники АХЧ',
|
||
'psychologist': 'Доступны психологи',
|
||
'speech_therapist': 'Доступны логопеды',
|
||
'hr': 'Доступны сотрудники кадровой службы',
|
||
'certificate': 'Доступны секретари и завучи',
|
||
'e_journal': 'Доступны администраторы электронного журнала'
|
||
};
|
||
|
||
taskTypeInfo.textContent = typeInfo[type] || '';
|
||
}
|
||
// Показываем/скрываем дополнительные поля в зависимости от типа
|
||
showAdditionalFieldsForType(type);
|
||
}
|
||
// Функция для показа дополнительных полей
|
||
function showAdditionalFieldsForType(type) {
|
||
// Пока скрываем все дополнительные поля (если они есть)
|
||
hideAllAdditionalFields();
|
||
|
||
// Показываем поля в зависимости от типа
|
||
switch(type) {
|
||
case 'certificate':
|
||
showCertificateFields();
|
||
break;
|
||
case 'e_journal':
|
||
showEJournalFields();
|
||
break;
|
||
case 'psychologist':
|
||
case 'speech_therapist':
|
||
showSpecialistFields(type);
|
||
break;
|
||
// Можно добавить другие типы
|
||
}
|
||
}
|
||
// Функции для управления дополнительными полями
|
||
function hideAllAdditionalFields() {
|
||
// Если есть дополнительные поля, скрываем их
|
||
// Пока оставляем пустым, можно добавить позже
|
||
}
|
||
|
||
function showCertificateFields() {
|
||
// Можно добавить поля для справок
|
||
// Например: тип справки, для кого, срок действия
|
||
}
|
||
|
||
function showEJournalFields() {
|
||
// Можно добавить поля для доступа к электронному журналу
|
||
// Например: логин, класс, предметы
|
||
}
|
||
|
||
function showSpecialistFields(type) {
|
||
// Можно добавить поля для специалистов
|
||
// Например: ученик, класс, причина обращения
|
||
}
|
||
// Вспомогательная функция для получения названия типа задачи
|
||
function getTaskTypeName(type) {
|
||
const typeNames = {
|
||
'regular': 'задачу',
|
||
'document': 'название документа',
|
||
'it': 'описание проблемы',
|
||
'ahch': 'описание заявки',
|
||
'psychologist': 'повод для обращения к психологу',
|
||
'speech_therapist': 'повод для обращения к логопеду',
|
||
'hr': 'вопрос к кадровой службе',
|
||
'certificate': 'тип необходимой справки',
|
||
'e_journal': 'информацию для доступа к журналу'
|
||
};
|
||
return typeNames[type] || 'задачу';
|
||
}
|
||
// Функция для получения отображаемого имени типа задачи
|
||
function getTaskTypeDisplayName(type) {
|
||
const typeNames = {
|
||
'regular': 'Задача',
|
||
'document': 'Документ',
|
||
'it': 'ИТ',
|
||
'ahch': 'АХЧ',
|
||
'psychologist': 'Психолог',
|
||
'speech_therapist': 'Логопед',
|
||
'hr': 'Кадры',
|
||
'certificate': 'Справка',
|
||
'e_journal': 'Эл. журнал'
|
||
};
|
||
return typeNames[type] || type;
|
||
}
|
||
|
||
// Функция для получения иконки типа задачи
|
||
function getTaskTypeIcon(type) {
|
||
const icons = {
|
||
'regular': 'fas fa-tasks',
|
||
'document': 'fas fa-file-signature',
|
||
'it': 'fas fa-desktop',
|
||
'ahch': 'fas fa-tools',
|
||
'psychologist': 'fas fa-brain',
|
||
'speech_therapist': 'fas fa-comment-medical',
|
||
'hr': 'fas fa-users',
|
||
'certificate': 'fas fa-file-certificate',
|
||
'e_journal': 'fas fa-book'
|
||
};
|
||
return icons[type] || 'fas fa-tasks';
|
||
}
|
||
|
||
// Функция для открытия модального окна управления исполнителями
|
||
async function openManageAssigneesModal(taskId) {
|
||
// Находим задачу
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
alert('Задача не найдена');
|
||
return;
|
||
}
|
||
|
||
// Проверяем права
|
||
if (!canUserEditTask(task)) {
|
||
alert('У вас нет прав для управления исполнителями этой задачи');
|
||
return;
|
||
}
|
||
|
||
// Получаем текущих исполнителей
|
||
const currentAssignees = task.assignments || [];
|
||
|
||
// Получаем доступных для добавления исполнителей
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/available-assignees`);
|
||
const availableUsers = await response.json();
|
||
|
||
// Получаем всех пользователей для выбора замены
|
||
const allUsersResponse = await fetch('/api/users');
|
||
const allUsers = await allUsersResponse.json();
|
||
|
||
// Создаем модальное окно
|
||
const modalHtml = `
|
||
<div class="modal" id="manage-assignees-modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3>Управление исполнителями задачи: "${task.title}"</h3>
|
||
<span class="close" onclick="closeManageAssigneesModal()">×</span>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="tabs">
|
||
<button class="tab-btn active" onclick="switchManageTab('add')">➕ Добавить</button>
|
||
<button class="tab-btn" onclick="switchManageTab('remove')">➖ Удалить</button>
|
||
<button class="tab-btn" onclick="switchManageTab('replace-one')">🔄 Заменить одного</button>
|
||
<button class="tab-btn" onclick="switchManageTab('replace-all')">🔄 Заменить всех</button>
|
||
<button class="tab-btn" onclick="switchManageTab('assign-to-user')">👤 Назначить всем</button>
|
||
</div>
|
||
|
||
<div id="add-assignee-tab" class="tab-content active">
|
||
<h4>Добавить исполнителей</h4>
|
||
<p>Текущие исполнители: ${currentAssignees.map(a => a.user_name).join(', ') || 'нет'}</p>
|
||
|
||
<div class="user-search-box">
|
||
<input type="text" id="available-users-search" placeholder="Поиск пользователей..."
|
||
oninput="filterAvailableUsers()">
|
||
</div>
|
||
|
||
<div class="users-checklist" id="available-users-list" style="max-height: 200px; overflow-y: auto;">
|
||
${availableUsers.map(user => `
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" class="available-user-checkbox" value="${user.id}">
|
||
${user.name}
|
||
</label>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-primary" onclick="addAssignees(${taskId})">Добавить выбранных</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="remove-assignee-tab" class="tab-content">
|
||
<h4>Удалить исполнителей</h4>
|
||
${currentAssignees.length > 0 ? `
|
||
<div class="users-checklist" style="max-height: 200px; overflow-y: auto;">
|
||
${currentAssignees.map(assignee => `
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" class="remove-user-checkbox" value="${assignee.user_id}">
|
||
${assignee.user_name} (${assignee.user_login})
|
||
<small>Статус: ${getStatusText(assignee.status)}</small>
|
||
</label>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-danger" onclick="removeAssignees(${taskId})">Удалить выбранных</button>
|
||
</div>
|
||
` : '<p>Нет исполнителей для удаления</p>'}
|
||
</div>
|
||
|
||
<div id="replace-one-assignee-tab" class="tab-content">
|
||
<h4>Заменить одного исполнителя</h4>
|
||
${currentAssignees.length > 0 ? `
|
||
<div class="form-group">
|
||
<label>Выберите исполнителя для замены:</label>
|
||
<select id="old-assignee-select" class="form-control">
|
||
${currentAssignees.map(assignee => `
|
||
<option value="${assignee.user_id}">${assignee.user_name} (${assignee.user_login})</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label>Выберите нового исполнителя:</label>
|
||
<select id="new-assignee-select" class="form-control">
|
||
<option value="">-- Выберите пользователя --</option>
|
||
${allUsers
|
||
.filter(user => !currentAssignees.some(a => a.user_id === user.id))
|
||
.map(user => `
|
||
<option value="${user.id}">${user.name} (${user.email})</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-warning" onclick="replaceAssignee(${taskId})">Заменить</button>
|
||
</div>
|
||
` : '<p>Нет исполнителей для замены</p>'}
|
||
</div>
|
||
|
||
<div id="replace-all-assignee-tab" class="tab-content">
|
||
<h4>Заменить всех исполнителей</h4>
|
||
<p>Текущие исполнители будут удалены, будут назначены новые.</p>
|
||
|
||
<div class="user-search-box">
|
||
<input type="text" id="replace-all-users-search" placeholder="Поиск пользователей..."
|
||
oninput="filterReplaceAllUsers()">
|
||
</div>
|
||
|
||
<div class="users-checklist" id="replace-all-users-list" style="max-height: 200px; overflow-y: auto;">
|
||
${allUsers.map(user => `
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" class="replace-all-user-checkbox" value="${user.id}">
|
||
${user.name}
|
||
</label>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-warning" onclick="replaceAllAssignees(${taskId})">Заменить всех</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="assign-to-user-tab" class="tab-content">
|
||
<h4>Назначить всем выбранному пользователю</h4>
|
||
<p>Все текущие исполнители будут удалены, задача будет назначена только выбранному пользователю.</p>
|
||
|
||
<div class="form-group">
|
||
<label>Выберите пользователя:</label>
|
||
<select id="target-user-select" class="form-control">
|
||
<option value="">-- Выберите пользователя --</option>
|
||
${allUsers.map(user => `
|
||
<option value="${user.id}" ${user.login === 'kalugin.o' ? 'selected' : ''}>
|
||
${user.name} (${user.email})
|
||
</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-primary" onclick="assignAllToUser(${taskId})">Назначить всем</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавляем модальное окно в DOM
|
||
const modalContainer = document.createElement('div');
|
||
modalContainer.innerHTML = modalHtml;
|
||
document.body.appendChild(modalContainer);
|
||
|
||
// Показываем модальное окно
|
||
setTimeout(() => {
|
||
document.getElementById('manage-assignees-modal').style.display = 'block';
|
||
}, 10);
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки данных:', error);
|
||
alert('Ошибка загрузки данных пользователей');
|
||
}
|
||
}
|
||
// ========== ДОБАВЛЕНИЯ ИСПОЛНИТЕЛЕЙ ==========
|
||
async function assignAdd_openModal(taskId) {
|
||
// Находим задачу
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
alert('Задача не найдена');
|
||
return;
|
||
}
|
||
|
||
// Проверяем права
|
||
if (!canUserEditTask(task)) {
|
||
alert('У вас нет прав для управления исполнителями этой задачи');
|
||
return;
|
||
}
|
||
|
||
// Получаем текущих исполнителей
|
||
const currentAssignees = task.assignments || [];
|
||
|
||
// Получаем доступных для добавления исполнителей
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/available-assignees`);
|
||
//const availableUsers = await response.json();
|
||
let availableUsers = await response.json();
|
||
|
||
// 👇 ИСКЛЮЧАЕМ АВТОРА ЗАДАЧИ ИЗ СПИСКА 👇
|
||
const taskCreatorId = task.created_by;
|
||
availableUsers = availableUsers.filter(user =>
|
||
parseInt(user.id) !== parseInt(taskCreatorId)
|
||
);
|
||
|
||
// Создаем модальное окно
|
||
const modalHtml = `
|
||
<div class="modal" id="manage-assignees-modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3>Управление исполнителями задачи: "${task.title}"</h3>
|
||
<span class="close" onclick="closeManageAssigneesModal()">×</span>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="add-assignee-tab" class="tab-content active">
|
||
<h4>Добавить исполнителей</h4>
|
||
<div class="user-search-box">
|
||
<input type="text" id="available-users-search" placeholder="Поиск пользователей..."
|
||
oninput="filterAvailableUsers()">
|
||
</div>
|
||
<div class="users-checklist" id="available-users-list" style="max-height: 200px; overflow-y: auto;">
|
||
${availableUsers.map(user => `
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" class="available-user-checkbox" value="${user.id}">
|
||
${user.name}
|
||
</label>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-primary" onclick="addAssignees(${taskId})">Добавить выбранных</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавляем модальное окно в DOM
|
||
const modalContainer = document.createElement('div');
|
||
modalContainer.innerHTML = modalHtml;
|
||
document.body.appendChild(modalContainer);
|
||
|
||
// Показываем модальное окно
|
||
setTimeout(() => {
|
||
document.getElementById('manage-assignees-modal').style.display = 'block';
|
||
}, 10);
|
||
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки данных:', error);
|
||
alert('Ошибка загрузки данных пользователей');
|
||
}
|
||
}
|
||
// ========== УДАЛЕНИЯ ИСПОЛНИТЕЛЕЙ ==========
|
||
async function assignRemove_openModal(taskId) {
|
||
// Находим задачу
|
||
const task = tasks.find(t => t.id === taskId);
|
||
if (!task) {
|
||
alert('Задача не найдена');
|
||
return;
|
||
}
|
||
// Проверяем права
|
||
if (!canUserEditTask(task)) {
|
||
alert('У вас нет прав для управления исполнителями этой задачи');
|
||
return;
|
||
}
|
||
// Получаем текущих исполнителей и фильтруем удаленных
|
||
const currentAssignees = (task.assignments || []).filter(assignee =>
|
||
assignee.status !== 'deleted' && assignee.status !== 'удален'
|
||
);
|
||
// Создаем модальное окно
|
||
const modalHtml = `
|
||
<div class="modal" id="manage-assignees-modal">
|
||
<div class="modal-content" style="max-width: 800px;">
|
||
<div class="modal-header">
|
||
<h3>Управление исполнителями задачи: "${task.title}"</h3>
|
||
<span class="close" onclick="closeManageAssigneesModal()">×</span>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="remove-assignee-tab" class="tab-content">
|
||
<h4>Удалить исполнителей</h4>
|
||
${currentAssignees.length > 0 ? `
|
||
<div class="users-checklist" style="max-height: 200px; overflow-y: auto;">
|
||
${currentAssignees.map(assignee => `
|
||
<div class="checkbox-item">
|
||
<label>
|
||
<input type="checkbox" class="remove-user-checkbox" value="${assignee.user_id}">
|
||
${assignee.user_name} (${assignee.user_login})
|
||
<small>Статус: ${getStatusText(assignee.status)}</small>
|
||
</label>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn-cancel" onclick="closeManageAssigneesModal()">Отмена</button>
|
||
<button type="button" class="btn-danger" onclick="removeAssignees(${taskId})">Удалить выбранных</button>
|
||
</div>
|
||
` : '<p>Нет исполнителей для удаления</p>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Добавляем модальное окно в DOM
|
||
const modalContainer = document.createElement('div');
|
||
modalContainer.innerHTML = modalHtml;
|
||
document.body.appendChild(modalContainer);
|
||
|
||
// Показываем модальное окно
|
||
setTimeout(() => {
|
||
document.getElementById('manage-assignees-modal').style.display = 'block';
|
||
}, 10);
|
||
}
|
||
|
||
// Функция для переключения вкладок в модальном окне
|
||
function switchManageTab(tabName) {
|
||
// Убираем активный класс со всех вкладок и контента
|
||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||
|
||
// Активируем выбранную вкладку
|
||
const activeTabBtn = document.querySelector(`.tab-btn[onclick*="${tabName}"]`);
|
||
if (activeTabBtn) activeTabBtn.classList.add('active');
|
||
|
||
const activeContent = document.getElementById(`${tabName}-tab`);
|
||
if (activeContent) activeContent.classList.add('active');
|
||
}
|
||
|
||
// Функция для фильтрации доступных пользователей
|
||
function filterAvailableUsers() {
|
||
const search = document.getElementById('available-users-search')?.value.toLowerCase() || '';
|
||
const checkboxes = document.querySelectorAll('.available-user-checkbox');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const label = checkbox.parentElement.textContent.toLowerCase();
|
||
const isVisible = label.includes(search) || search === '';
|
||
checkbox.parentElement.parentElement.style.display = isVisible ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// Функция для фильтрации пользователей для замены всех
|
||
function filterReplaceAllUsers() {
|
||
const search = document.getElementById('replace-all-users-search')?.value.toLowerCase() || '';
|
||
const checkboxes = document.querySelectorAll('.replace-all-user-checkbox');
|
||
|
||
checkboxes.forEach(checkbox => {
|
||
const label = checkbox.parentElement.textContent.toLowerCase();
|
||
const isVisible = label.includes(search) || search === '';
|
||
checkbox.parentElement.parentElement.style.display = isVisible ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// Функция для добавления исполнителей
|
||
async function addAssignees(taskId) {
|
||
const selectedCheckboxes = document.querySelectorAll('.available-user-checkbox:checked');
|
||
const assigneeIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
||
|
||
if (assigneeIds.length === 0) {
|
||
alert('Выберите хотя бы одного пользователя для добавления');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/assignees`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ assigneeIds })
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
alert(result.message);
|
||
closeManageAssigneesModal();
|
||
loadTasks(); // Перезагружаем задачи
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка:', error);
|
||
alert('Сетевая ошибка при добавлении исполнителей');
|
||
}
|
||
}
|
||
|
||
// Функция для удаления исполнителей
|
||
async function removeAssignees(taskId) {
|
||
const selectedCheckboxes = document.querySelectorAll('.remove-user-checkbox:checked');
|
||
const assigneeIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
||
|
||
if (assigneeIds.length === 0) {
|
||
alert('Выберите хотя бы одного исполнителя для удаления');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Вы уверены, что хотите удалить ${assigneeIds.length} исполнителей из задачи?`)) {
|
||
return;
|
||
}
|
||
|
||
// Удаляем каждого исполнителя по отдельности
|
||
const results = [];
|
||
for (const assigneeId of assigneeIds) {
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/assignees/${assigneeId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (response.ok) {
|
||
results.push({ id: assigneeId, success: true });
|
||
} else {
|
||
const error = await response.json();
|
||
results.push({ id: assigneeId, success: false, error: error.error });
|
||
}
|
||
} catch (error) {
|
||
results.push({ id: assigneeId, success: false, error: error.message });
|
||
}
|
||
}
|
||
|
||
const successful = results.filter(r => r.success).length;
|
||
const failed = results.filter(r => !r.success);
|
||
|
||
if (failed.length > 0) {
|
||
alert(`Удалено ${successful} исполнителей. Ошибки: ${failed.map(f => `ID ${f.id}: ${f.error}`).join('; ')}`);
|
||
} else {
|
||
alert(`Успешно удалено ${successful} исполнителей`);
|
||
}
|
||
|
||
closeManageAssigneesModal();
|
||
loadTasks(); // Перезагружаем задачи
|
||
}
|
||
|
||
// Функция для замены одного исполнителя
|
||
async function replaceAssignee(taskId) {
|
||
const oldAssigneeId = document.getElementById('old-assignee-select').value;
|
||
const newAssigneeId = document.getElementById('new-assignee-select').value;
|
||
|
||
if (!oldAssigneeId || !newAssigneeId) {
|
||
alert('Выберите старого и нового исполнителя');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Вы уверены, что хотите заменить исполнителя?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/replace-assignee`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ oldAssigneeId, newAssigneeId })
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
alert(result.message);
|
||
closeManageAssigneesModal();
|
||
loadTasks(); // Перезагружаем задачи
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка:', error);
|
||
alert('Сетевая ошибка при замене исполнителя');
|
||
}
|
||
}
|
||
|
||
// Функция для замены всех исполнителей
|
||
async function replaceAllAssignees(taskId) {
|
||
const selectedCheckboxes = document.querySelectorAll('.replace-all-user-checkbox:checked');
|
||
const newAssigneeIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
||
|
||
if (newAssigneeIds.length === 0) {
|
||
alert('Выберите хотя бы одного нового исполнителя');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Вы уверены, что хотите заменить всех исполнителей? Текущие исполнители будут удалены.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/replace-all-assignees`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ newAssigneeIds })
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
alert(result.message);
|
||
closeManageAssigneesModal();
|
||
loadTasks(); // Перезагружаем задачи
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка:', error);
|
||
alert('Сетевая ошибка при замене исполнителей');
|
||
}
|
||
}
|
||
|
||
// Функция для назначения всех исполнителей одному пользователю
|
||
async function assignAllToUser(taskId) {
|
||
const targetUserId = document.getElementById('target-user-select').value;
|
||
|
||
if (!targetUserId) {
|
||
alert('Выберите пользователя для назначения');
|
||
return;
|
||
}
|
||
|
||
if (!confirm('Вы уверены, что хотите назначить задачу только этому пользователю? Все текущие исполнители будут удалены.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/tasks/${taskId}/assign-all-to-user`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ targetUserId })
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
alert(result.message);
|
||
closeManageAssigneesModal();
|
||
loadTasks(); // Перезагружаем задачи
|
||
} else {
|
||
const error = await response.json();
|
||
alert(`Ошибка: ${error.error || 'Неизвестная ошибка'}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка:', error);
|
||
alert('Сетевая ошибка при назначении задачи');
|
||
}
|
||
}
|
||
|
||
// Функция для закрытия модального окна управления исполнителями
|
||
function closeManageAssigneesModal() {
|
||
const modal = document.getElementById('manage-assignees-modal');
|
||
if (modal) {
|
||
modal.style.display = 'none';
|
||
// Удаляем модальное окно из DOM через некоторое время
|
||
setTimeout(() => {
|
||
modal.parentElement.remove();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
// Обновленная функция рендеринга файлов с группировкой по пользователям
|
||
function renderGroupedFiles(task) {
|
||
if (!task.files || task.files.length === 0) {
|
||
return '<span class="no-files">нет файлов</span>';
|
||
}
|
||
|
||
// 1. Группируем ВСЕ файлы по пользователю, который их загрузил
|
||
const filesByUploader = {};
|
||
|
||
// ВАЖНО: Используем все файлы задачи, а не отфильтрованные
|
||
task.files.forEach(file => {
|
||
const uploaderId = file.user_id;
|
||
const uploaderName = file.user_name || 'Неизвестный пользователь';
|
||
|
||
if (!filesByUploader[uploaderId]) {
|
||
filesByUploader[uploaderId] = {
|
||
name: uploaderName,
|
||
id: uploaderId,
|
||
files: []
|
||
};
|
||
}
|
||
filesByUploader[uploaderId].files.push(file);
|
||
});
|
||
|
||
// 2. Определяем, какие группы (загрузчики) может видеть текущий пользователь
|
||
const visibleGroups = [];
|
||
|
||
for (const uploaderId in filesByUploader) {
|
||
const uploaderGroup = filesByUploader[uploaderId];
|
||
const uploaderIdNum = parseInt(uploaderId);
|
||
|
||
// Проверяем, может ли текущий пользователь видеть файлы этого загрузчика
|
||
let canSeeThisUploader = false;
|
||
|
||
// 1. Администратор видит все файлы всех загрузчиков
|
||
if (currentUser.role === 'admin') {
|
||
canSeeThisUploader = true;
|
||
}
|
||
// 2. Создатель задачи видит все файлы всех загрузчиков
|
||
else if (parseInt(task.created_by) === currentUser.id) {
|
||
canSeeThisUploader = true;
|
||
}
|
||
// 3. Исполнитель видит:
|
||
// - Файлы создателя задачи
|
||
// - Свои файлы (если он их загрузил)
|
||
else {
|
||
const creatorId = parseInt(task.created_by);
|
||
|
||
// Файлы загружены создателем задачи
|
||
if (uploaderIdNum === creatorId) {
|
||
canSeeThisUploader = true;
|
||
}
|
||
// Файлы загружены текущим пользователем
|
||
else if (uploaderIdNum === currentUser.id) {
|
||
canSeeThisUploader = true;
|
||
}
|
||
// Если файлы загружены другим исполнителем - не показываем эту группу
|
||
else {
|
||
canSeeThisUploader = false;
|
||
}
|
||
}
|
||
|
||
// Если текущий пользователь может видеть эту группу, добавляем её
|
||
if (canSeeThisUploader) {
|
||
// В группе показываем ТОЛЬКО файлы этого загрузчика
|
||
visibleGroups.push({
|
||
name: uploaderGroup.name,
|
||
id: uploaderGroup.id,
|
||
files: uploaderGroup.files // Все файлы этого загрузчика
|
||
});
|
||
}
|
||
}
|
||
|
||
if (visibleGroups.length === 0) {
|
||
return '<span class="no-files">нет файлов</span>';
|
||
}
|
||
|
||
// 3. Рендерим только видимые группы
|
||
// Если файлы загружены только одним пользователем, показываем просто список
|
||
if (visibleGroups.length === 1) {
|
||
const uploader = visibleGroups[0];
|
||
return `
|
||
<div class="file-group single-user">
|
||
<div class="file-group-header">
|
||
<strong>${uploader.name}:</strong>
|
||
</div>
|
||
<div class="file-icons-container">
|
||
${uploader.files.map(file => renderFileIcon(file)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Если файлы загружены несколькими пользователями, группируем
|
||
return visibleGroups.map(uploader => `
|
||
<div class="file-group">
|
||
<div class="file-group-header">
|
||
<strong>${uploader.name}:</strong>
|
||
</div>
|
||
<div class="file-icons-container">
|
||
${uploader.files.map(file => renderFileIcon(file)).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} |