1
0

Фиксы...

This commit is contained in:
2026-01-19 15:10:37 +07:00
parent 7d7b817d7e
commit 8e3cd770df
17 changed files with 281 additions and 111 deletions

View File

@@ -93,6 +93,14 @@ if ($method === 'POST') {
$project_id = $taskData['id_project']; $project_id = $taskData['id_project'];
ProjectAccess::requireAccess($project_id, $current_user_id); ProjectAccess::requireAccess($project_id, $current_user_id);
// Нельзя загружать файлы к комментариям архивных задач
if ((int)$taskData['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя загружать файлы к комментариям архивных задач']
], 400);
}
// Проверяем право на загрузку картинок // Проверяем право на загрузку картинок
ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images'); ProjectAccess::requirePermission($project_id, $current_user_id, 'upload_images');
@@ -119,6 +127,14 @@ if ($method === 'POST') {
$project_id = $taskData['id_project']; $project_id = $taskData['id_project'];
ProjectAccess::requireAccess($project_id, $current_user_id); ProjectAccess::requireAccess($project_id, $current_user_id);
// Нельзя удалять файлы из комментариев архивных задач
if ((int)$taskData['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять файлы из комментариев архивных задач']
], 400);
}
// Проверка прав: автор комментария ИЛИ админ проекта // Проверка прав: автор комментария ИЛИ админ проекта
$isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id; $isAuthor = (int)$commentData['id_accounts'] === (int)$current_user_id;
$isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id); $isAdmin = ProjectAccess::isAdmin($project_id, $current_user_id);

View File

@@ -78,6 +78,12 @@ if ($method === 'POST') {
// Проверяем право на создание задач // Проверяем право на создание задач
ProjectAccess::requirePermission($project_id, $user_id, 'create_task'); ProjectAccess::requirePermission($project_id, $user_id, 'create_task');
// Проверяем что у проекта есть колонки
$columns = Project::getColumns($project_id);
if (empty($columns)) {
RestApi::response(['success' => false, 'errors' => ['project' => 'У проекта нет колонок. Создайте хотя бы одну колонку.']], 400);
}
$task->id_project = $project_id; $task->id_project = $project_id;
$task->id_department = $data['id_department'] ?? null; $task->id_department = $data['id_department'] ?? null;
$task->id_label = $data['id_label'] ?? null; $task->id_label = $data['id_label'] ?? null;

View File

@@ -30,8 +30,12 @@ class Comment extends BaseEntity {
return $errors; return $errors;
} }
// Проверяем что задача существует // Проверяем что задача существует и не архивная
Task::check_task($this->id_task); $task = Task::check_task($this->id_task);
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя комментировать архивные задачи');
return $this->getErrors();
}
// Если это ответ — проверяем что родительский комментарий существует // Если это ответ — проверяем что родительский комментарий существует
if ($this->id_answer) { if ($this->id_answer) {
@@ -75,6 +79,13 @@ class Comment extends BaseEntity {
// Проверяем что комментарий существует // Проверяем что комментарий существует
$comment = self::checkComment($this->id); $comment = self::checkComment($this->id);
// Проверяем что задача не архивная
$task = Database::get('cards_task', ['archive'], ['id' => $comment['id_task']]);
if ($task && (int)$task['archive'] === 1) {
$this->addError('task', 'Нельзя редактировать комментарии архивных задач');
return $this->getErrors();
}
// Проверяем что пользователь — автор комментария // Проверяем что пользователь — автор комментария
if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) { if ((int)$comment['id_accounts'] !== (int)$this->id_accounts) {
$this->addError('access', 'Вы можете редактировать только свои комментарии'); $this->addError('access', 'Вы можете редактировать только свои комментарии');
@@ -99,8 +110,16 @@ class Comment extends BaseEntity {
// Проверяем что комментарий существует // Проверяем что комментарий существует
$comment = self::checkComment($id); $comment = self::checkComment($id);
// Получаем задачу для проверки админа проекта // Получаем задачу для проверки админа проекта и архивации
$task = Database::get('cards_task', ['id_project'], ['id' => $comment['id_task']]); $task = Database::get('cards_task', ['id_project', 'archive'], ['id' => $comment['id_task']]);
// Нельзя удалять комментарии архивных задач
if ($task && (int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять комментарии архивных задач']
], 400);
}
// Проверяем права: автор комментария ИЛИ админ проекта // Проверяем права: автор комментария ИЛИ админ проекта
$isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts; $isAuthor = (int)$comment['id_accounts'] === (int)$id_accounts;

View File

@@ -112,10 +112,10 @@ class Project extends BaseEntity {
// ==================== CRUD ПРОЕКТОВ ==================== // ==================== CRUD ПРОЕКТОВ ====================
// Создание проекта с дефолтными колонками // Создание проекта БЕЗ колонок (колонки создаются на фронте)
public static function create($name, $user_id) { public static function create($name, $user_id) {
// Получаем максимальный id_order // Получаем максимальный id_order
$maxOrder = Database::max('project', 'id_order') ?? 0; $maxOrder = (int)(Database::max('project', 'id_order') ?? 0);
// Создаём проект с создателем как владельцем (id_admin) // Создаём проект с создателем как владельцем (id_admin)
Database::insert('project', [ Database::insert('project', [
@@ -129,34 +129,9 @@ class Project extends BaseEntity {
return ['success' => false, 'errors' => ['project' => 'Ошибка создания проекта']]; return ['success' => false, 'errors' => ['project' => 'Ошибка создания проекта']];
} }
// Создаём дефолтные колонки
Database::insert('columns', [
'name_columns' => 'К выполнению',
'color' => '#6366f1',
'id_project' => $projectId,
'id_order' => 1
]);
$firstColumnId = Database::id();
Database::insert('columns', [
'name_columns' => 'Готово',
'color' => '#22c55e',
'id_project' => $projectId,
'id_order' => 2
]);
$readyColumnId = Database::id();
// Устанавливаем id_ready
Database::update('project', ['id_ready' => $readyColumnId], ['id' => $projectId]);
return [ return [
'success' => true, 'success' => true,
'id' => $projectId, 'id' => $projectId,
'columns' => [
['id' => $firstColumnId, 'name_columns' => 'К выполнению', 'color' => '#6366f1', 'id_order' => 1],
['id' => $readyColumnId, 'name_columns' => 'Готово', 'color' => '#22c55e', 'id_order' => 2]
],
'id_ready' => $readyColumnId,
'is_admin' => true 'is_admin' => true
]; ];
} }
@@ -237,7 +212,7 @@ class Project extends BaseEntity {
} }
// Получаем максимальный id_order для проекта // Получаем максимальный id_order для проекта
$maxOrder = Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0; $maxOrder = (int)(Database::max('columns', 'id_order', ['id_project' => $project_id]) ?? 0);
Database::insert('columns', [ Database::insert('columns', [
'name_columns' => $name, 'name_columns' => $name,

View File

@@ -61,6 +61,9 @@ class Task extends BaseEntity {
return $errors; return $errors;
} }
// Формируем дату создания (одна переменная для БД и ответа)
$date_create = date('Y-m-d H:i:s');
// Вставляем в базу // Вставляем в базу
Database::insert($this->db_name, [ Database::insert($this->db_name, [
'id_project' => $this->id_project, 'id_project' => $this->id_project,
@@ -75,7 +78,7 @@ class Task extends BaseEntity {
'descript' => $this->descript ?: null, 'descript' => $this->descript ?: null,
'descript_full' => $this->descript_full ?: null, 'descript_full' => $this->descript_full ?: null,
'archive' => 0, 'archive' => 0,
'date_create' => date('Y-m-d H:i:s'), 'date_create' => $date_create,
'file_img' => '[]' 'file_img' => '[]'
]); ]);
@@ -96,6 +99,9 @@ class Task extends BaseEntity {
return [ return [
'success' => true, 'success' => true,
'id' => $this->id, 'id' => $this->id,
'date' => $this->date ?: null,
'date_create' => $date_create,
'date_closed' => null,
'files' => $uploaded_files 'files' => $uploaded_files
]; ];
} }
@@ -108,12 +114,18 @@ class Task extends BaseEntity {
} }
// Проверка что задача существует и получаем текущие данные // Проверка что задача существует и получаем текущие данные
$task = Database::get($this->db_name, ['id', 'column_id', 'order', 'id_project'], ['id' => $this->id]); $task = Database::get($this->db_name, ['id', 'column_id', 'order', 'id_project', 'archive'], ['id' => $this->id]);
if (!$task) { if (!$task) {
$this->addError('task', 'Задача не найдена'); $this->addError('task', 'Задача не найдена');
return $this->getErrors(); return $this->getErrors();
} }
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
$this->addError('task', 'Архивные задачи нельзя редактировать');
return $this->getErrors();
}
// Получаем текущую колонку // Получаем текущую колонку
$old_column_id = (int)$task['column_id']; $old_column_id = (int)$task['column_id'];
@@ -179,14 +191,32 @@ class Task extends BaseEntity {
// Загрузка файла к задаче // Загрузка файла к задаче
public static function uploadFile($task_id, $file_base64, $file_name) { public static function uploadFile($task_id, $file_base64, $file_name) {
// Проверка что задача существует // Проверка что задача существует
self::check_task($task_id); $task = self::check_task($task_id);
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя загружать файлы в архивную задачу']
], 400);
}
return FileUpload::upload('task', $task_id, $file_base64, $file_name); return FileUpload::upload('task', $task_id, $file_base64, $file_name);
} }
// Удаление файлов задачи // Удаление файлов задачи
public static function deleteFile($task_id, $file_names) { public static function deleteFile($task_id, $file_names) {
// Проверка что задача существует // Проверка что задача существует
self::check_task($task_id); $task = self::check_task($task_id);
// Архивные задачи нельзя редактировать
if ((int)$task['archive'] === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Нельзя удалять файлы из архивной задачи']
], 400);
}
return FileUpload::delete('task', $task_id, $file_names); return FileUpload::delete('task', $task_id, $file_names);
} }
@@ -199,6 +229,14 @@ class Task extends BaseEntity {
$new_column_id = (int)$column_id; $new_column_id = (int)$column_id;
$archive = (int)$task['archive']; $archive = (int)$task['archive'];
// Архивные задачи нельзя перемещать
if ($archive === 1) {
RestApi::response([
'success' => false,
'errors' => ['task' => 'Архивные задачи нельзя перемещать']
], 400);
}
// Получаем id_ready (колонка "Готово") из проекта // Получаем id_ready (колонка "Готово") из проекта
$done_column_id = Project::getReadyColumnId($task['id_project']); $done_column_id = Project::getReadyColumnId($task['id_project']);

View File

@@ -2,11 +2,11 @@
window.APP_CONFIG = { window.APP_CONFIG = {
API_BASE: 'http://192.168.1.6', API_BASE: 'http://192.168.1.6',
// Интервалы автообновления данных (в секундах) // Интервалы автообновления данных (в секундах, 0 = отключено)
REFRESH_INTERVALS: { REFRESH_INTERVALS: {
cards: 2, // Карточки на доске cards: 2, // Карточки на доске
comments: 5, // Комментарии к задаче comments: 5, // Комментарии к задаче
invites: 10 // Приглашения на странице без проектов invites: 5 // Приглашения (страница: задачи + страница без проектов)
}, },
// Брейкпоинт для мобильной версии (px) // Брейкпоинт для мобильной версии (px)

View File

@@ -396,11 +396,15 @@ export const serverSettings = {
parseDate(dateStr) { parseDate(dateStr) {
if (!dateStr) return null if (!dateStr) return null
// Добавляем таймзону сервера для корректного парсинга // Добавляем таймзону сервера для корректного парсинга
const normalized = dateStr.replace(' ', 'T') let normalized = dateStr.replace(' ', 'T')
// Если уже есть таймзона — не добавляем // Если уже есть таймзона — не добавляем
if (normalized.includes('+') || normalized.includes('Z')) { if (normalized.includes('+') || normalized.includes('Z')) {
return new Date(normalized) return new Date(normalized)
} }
// Если нет времени (только дата YYYY-MM-DD) — добавляем 00:00:00
if (normalized.length === 10) {
normalized += 'T00:00:00'
}
return new Date(normalized + this.timezoneOffset) return new Date(normalized + this.timezoneOffset)
} }
} }

View File

@@ -321,7 +321,7 @@ const saveTask = async (taskData, columnId) => {
}) })
if (result.success) { if (result.success) {
// Добавляем локально с ID от сервера // Добавляем локально с данными от сервера
localCards.value.push({ localCards.value.push({
id: parseInt(result.id), id: parseInt(result.id),
id_department: taskData.departmentId, id_department: taskData.departmentId,
@@ -332,8 +332,9 @@ const saveTask = async (taskData, columnId) => {
descript_full: taskData.details, descript_full: taskData.details,
avatar_img: taskData.assignee, avatar_img: taskData.assignee,
column_id: columnId, column_id: columnId,
date: taskData.dueDate, date: result.date,
date_create: new Date().toISOString().split('T')[0], date_create: result.date_create,
date_closed: result.date_closed,
order: maxOrder, order: maxOrder,
files: result.files || [] files: result.files || []
}) })

View File

@@ -84,7 +84,7 @@ const dialogShowDiscard = computed(() => props.showDiscard ?? config.value.showD
const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default') const dialogVariant = computed(() => props.variant ?? config.value.variant ?? 'default')
const dialogDiscardVariant = computed(() => props.discardVariant ?? config.value.discardVariant ?? 'default') const dialogDiscardVariant = computed(() => props.discardVariant ?? config.value.discardVariant ?? 'default')
const emit = defineEmits(['confirm', 'cancel', 'discard']) const emit = defineEmits(['confirm', 'cancel', 'discard', 'update:show'])
// Внутреннее состояние загрузки: null | 'confirm' | 'discard' // Внутреннее состояние загрузки: null | 'confirm' | 'discard'
const loading = ref(null) const loading = ref(null)
@@ -104,8 +104,9 @@ const handleConfirm = async () => {
loading.value = 'confirm' loading.value = 'confirm'
try { try {
await props.action() await props.action()
// Успех — эмитим confirm для закрытия // Успех — эмитим confirm и закрываем диалог
emit('confirm') emit('confirm')
emit('update:show', false)
} catch (e) { } catch (e) {
console.error('ConfirmDialog action failed:', e) console.error('ConfirmDialog action failed:', e)
// При ошибке — не закрываем диалог // При ошибке — не закрываем диалог
@@ -113,14 +114,16 @@ const handleConfirm = async () => {
loading.value = null loading.value = null
} }
} else { } else {
// Простой режим — просто эмитим // Простой режим — просто эмитим и закрываем
emit('confirm') emit('confirm')
emit('update:show', false)
} }
} }
const handleCancel = () => { const handleCancel = () => {
if (loading.value) return if (loading.value) return
emit('cancel') emit('cancel')
emit('update:show', false)
} }
const handleDiscard = async () => { const handleDiscard = async () => {
@@ -131,8 +134,9 @@ const handleDiscard = async () => {
loading.value = 'discard' loading.value = 'discard'
try { try {
await props.discardAction() await props.discardAction()
// Успех — эмитим discard для закрытия // Успех — эмитим discard и закрываем диалог
emit('discard') emit('discard')
emit('update:show', false)
} catch (e) { } catch (e) {
console.error('ConfirmDialog discardAction failed:', e) console.error('ConfirmDialog discardAction failed:', e)
// При ошибке — не закрываем диалог // При ошибке — не закрываем диалог
@@ -140,8 +144,9 @@ const handleDiscard = async () => {
loading.value = null loading.value = null
} }
} else { } else {
// Простой режим — просто эмитим // Простой режим — просто эмитим и закрываем
emit('discard') emit('discard')
emit('update:show', false)
} }
} }
</script> </script>

View File

@@ -785,6 +785,25 @@ const handleSave = async () => {
if (result.success) { if (result.success) {
const newProjectId = result.id const newProjectId = result.id
// Создаём колонки для нового проекта
const createdColumnIds = []
for (const column of form.value.columns) {
if (column.tempId) {
const colResult = await store.addColumn(column.name_columns, column.color)
if (colResult.success) {
createdColumnIds.push(colResult.id)
} else {
toast.error('Ошибка создания колонки')
}
}
}
// Устанавливаем последнюю колонку как финальную (id_ready)
if (createdColumnIds.length > 0) {
const lastColumnId = createdColumnIds[createdColumnIds.length - 1]
await store.reorderColumns(createdColumnIds) // Это автоматически установит id_ready
}
// Создаём отделы для нового проекта // Создаём отделы для нового проекта
for (const department of form.value.departments) { for (const department of form.value.departments) {
if (department.tempId) { if (department.tempId) {

View File

@@ -22,6 +22,7 @@
<div class="comment-actions"> <div class="comment-actions">
<IconButton <IconButton
v-if="canReply"
icon="reply" icon="reply"
variant="ghost" variant="ghost"
small small
@@ -110,6 +111,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false default: false
}, },
canReply: {
type: Boolean,
default: true
},
getFullUrl: { getFullUrl: {
type: Function, type: Function,
required: true required: true

View File

@@ -17,6 +17,7 @@
:level="comment.level" :level="comment.level"
:is-own="comment.id_accounts === currentUserId" :is-own="comment.id_accounts === currentUserId"
:can-edit="canEditComment(comment)" :can-edit="canEditComment(comment)"
:can-reply="canComment"
:get-full-url="getFullUrl" :get-full-url="getFullUrl"
@reply="startReply" @reply="startReply"
@edit="startEditComment" @edit="startEditComment"
@@ -68,6 +69,7 @@ import ContentEditorPanel from './ContentEditorPanel.vue'
import Loader from '../ui/Loader.vue' import Loader from '../ui/Loader.vue'
import { commentsApi, commentImageApi, getFullUrl } from '../../api' import { commentsApi, commentImageApi, getFullUrl } from '../../api'
import { useLucideIcons } from '../../composables/useLucideIcons' import { useLucideIcons } from '../../composables/useLucideIcons'
import { useAutoRefresh } from '../../composables/useAutoRefresh'
const props = defineProps({ const props = defineProps({
taskId: { taskId: {
@@ -150,8 +152,12 @@ const editorAvatarUrl = computed(() => {
// Refs // Refs
const commentsListRef = ref(null) const commentsListRef = ref(null)
// Интервал обновления // Автообновление комментариев
let refreshInterval = null const { start: startRefresh, stop: stopRefresh } = useAutoRefresh('comments', async () => {
if (props.active && props.taskId) {
await loadComments(true)
}
})
// Построение дерева комментариев // Построение дерева комментариев
const commentsTree = computed(() => { const commentsTree = computed(() => {
@@ -243,23 +249,7 @@ const loadComments = async (silent = false) => {
} }
} }
// Автообновление // startRefresh и stopRefresh определены через useAutoRefresh выше
const startRefresh = () => {
stopRefresh()
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
refreshInterval = setInterval(async () => {
if (props.active && props.taskId) {
await loadComments(true)
}
}, interval)
}
const stopRefresh = () => {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
// ========== Редактор: открытие/закрытие ========== // ========== Редактор: открытие/закрытие ==========
@@ -402,6 +392,8 @@ const updateComment = async (text, newFiles, filesToDelete) => {
// ========== Редактирование (права) ========== // ========== Редактирование (права) ==========
const canEditComment = (comment) => { const canEditComment = (comment) => {
// Для архивных задач — нельзя редактировать комментарии
if (!props.canComment) return false
return comment.id_accounts === props.currentUserId || props.isProjectAdmin return comment.id_accounts === props.currentUserId || props.isProjectAdmin
} }

View File

@@ -49,8 +49,8 @@
/> />
</template> </template>
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать --> <!-- Footer: скрываем на вкладке комментариев, показываем если есть права редактировать или это архивная задача -->
<template #footer v-if="activeTab !== 'comments' && canEdit"> <template #footer v-if="activeTab !== 'comments' && (canEdit || isArchived)">
<div class="footer-left"> <div class="footer-left">
<IconButton <IconButton
v-if="!isNew" v-if="!isNew"
@@ -74,13 +74,19 @@
@click="handleRestore" @click="handleRestore"
/> />
</div> </div>
<!-- Кнопки сохранения только для редактируемых задач -->
<ActionButtons <ActionButtons
v-if="canEdit"
:save-text="isNew ? 'Создать' : 'Сохранить'" :save-text="isNew ? 'Создать' : 'Сохранить'"
:loading="isSaving" :loading="isSaving"
:disabled="!canSave" :disabled="!canSave"
@save="handleSave" @save="handleSave"
@cancel="tryClose" @cancel="tryClose"
/> />
<!-- Для архивных только кнопка закрытия -->
<button v-else class="btn-close-panel" @click="tryClose">
Закрыть
</button>
</template> </template>
</SlidePanel> </SlidePanel>
@@ -228,14 +234,18 @@ const canArchive = computed(() => {
}) })
// Право на редактирование (для новой — create_task, для существующей — canEditTask) // Право на редактирование (для новой — create_task, для существующей — canEditTask)
// Архивные задачи нельзя редактировать
const canEdit = computed(() => { const canEdit = computed(() => {
if (props.isArchived) return false
if (isNew.value) return store.can('create_task') if (isNew.value) return store.can('create_task')
return store.canEditTask(props.card) return store.canEditTask(props.card)
}) })
// Право на создание комментариев // Право на создание комментариев
// Архивные задачи нельзя комментировать
const canComment = computed(() => { const canComment = computed(() => {
if (isNew.value) return false // В новой задаче нельзя комментировать if (isNew.value) return false // В новой задаче нельзя комментировать
if (props.isArchived) return false // Архивные нельзя комментировать
return store.canCreateComment(props.card) return store.canCreateComment(props.card)
}) })
@@ -429,7 +439,7 @@ watch(() => props.show, async (newVal) => {
isSaving.value = false // Сброс состояния кнопки сохранения isSaving.value = false // Сброс состояния кнопки сохранения
// Обновляем права пользователя (могли измениться администратором) // Обновляем права пользователя (могли измениться администратором)
store.fetchUsers() await store.fetchUsers()
// Reset comments tab // Reset comments tab
commentsTabRef.value?.reset() commentsTabRef.value?.reset()
@@ -472,4 +482,21 @@ watch(() => props.show, async (newVal) => {
gap: 8px; gap: 8px;
} }
.btn-close-panel {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-close-panel:hover {
background: rgba(255, 255, 255, 0.12);
color: var(--text-primary);
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<!-- Кнопка-триггер --> <!-- Кнопка-триггер -->
<button class="mobile-select-btn" :class="[variant, { compact }]" @click="open = true"> <button class="mobile-select-btn" :class="[variant, { compact, 'has-selection': hasSelection }]" @click="open = true">
<i v-if="icon" :data-lucide="icon" class="btn-icon"></i> <i v-if="icon" :data-lucide="icon" class="btn-icon"></i>
<span v-if="!compact" class="btn-label">{{ displayValue }}</span> <span v-if="!compact" class="btn-label">{{ displayValue }}</span>
<i data-lucide="chevron-down" class="btn-arrow"></i> <i data-lucide="chevron-down" class="btn-arrow"></i>
@@ -85,6 +85,14 @@ const displayValue = computed(() => {
return option?.label || props.placeholder return option?.label || props.placeholder
}) })
// Проверка: выбран ли не-дефолтный вариант (первый option обычно "Все")
const hasSelection = computed(() => {
if (props.modelValue === null || props.modelValue === undefined) return false
// Если первый option = null — значит "Все", проверяем что выбрано что-то другое
const firstOption = props.options[0]
return firstOption && props.modelValue !== firstOption.id
})
const selectOption = (id) => { const selectOption = (id) => {
emit('update:modelValue', id) emit('update:modelValue', id)
open.value = false open.value = false
@@ -191,6 +199,17 @@ onUpdated(refreshIcons)
display: none; display: none;
} }
/* Подсветка когда выбран активный фильтр */
.mobile-select-btn.has-selection {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.mobile-select-btn.has-selection .btn-icon {
opacity: 1;
}
.btn-icon { .btn-icon {
width: 14px; width: 14px;
height: 14px; height: 14px;

View File

@@ -0,0 +1,60 @@
import { ref } from 'vue'
/**
* Composable для автообновления данных
*
* @param {string} key - ключ из REFRESH_INTERVALS (cards, comments, invites)
* @param {Function} callback - функция для вызова при каждом обновлении
*
* @example
* const { start, stop } = useAutoRefresh('cards', async () => {
* await fetchCards()
* })
*
* onMounted(() => start())
* onUnmounted(() => stop())
*/
export function useAutoRefresh(key, callback) {
let timer = null
const isActive = ref(false)
// Получаем интервал из конфига (в секундах), конвертируем в мс
const getInterval = () => {
const seconds = window.APP_CONFIG?.REFRESH_INTERVALS?.[key] ?? 30
return seconds * 1000
}
const start = () => {
stop() // Очищаем предыдущий если был
const interval = getInterval()
// 0 = отключено
if (interval <= 0) {
return
}
isActive.value = true
timer = setInterval(async () => {
try {
await callback()
} catch (e) {
console.error(`[AutoRefresh:${key}] Error:`, e)
}
}, interval)
}
const stop = () => {
if (timer) {
clearInterval(timer)
timer = null
}
isActive.value = false
}
return {
start,
stop,
isActive
}
}

View File

@@ -119,6 +119,7 @@ import { useProjectsStore } from '../stores/projects'
import { cardsApi } from '../api' import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile' import { useMobile } from '../composables/useMobile'
import { useDepartmentFilter } from '../composables/useDepartmentFilter' import { useDepartmentFilter } from '../composables/useDepartmentFilter'
import { useAutoRefresh } from '../composables/useAutoRefresh'
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -237,37 +238,25 @@ const onProjectSaved = async (projectId) => {
} }
// ==================== АВТООБНОВЛЕНИЕ (POLLING) ==================== // ==================== АВТООБНОВЛЕНИЕ (POLLING) ====================
const CARDS_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.cards ?? 30) * 1000 const { start: startCardsPolling, stop: stopCardsPolling } = useAutoRefresh('cards', async () => {
const INVITES_REFRESH_INTERVAL = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites ?? 30) * 1000
let pollTimer = null
let invitesPollTimer = null
const startPolling = () => {
// Polling карточек
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(async () => {
// Не обновляем когда открыта модалка — это может прерывать клики // Не обновляем когда открыта модалка — это может прерывать клики
if (panelOpen.value || projectPanelOpen.value) return if (panelOpen.value || projectPanelOpen.value) return
console.log('[AutoRefresh] Обновление данных...') console.log('[AutoRefresh] Обновление данных...')
await fetchCards(true) // silent = true, без Loader await fetchCards(true) // silent = true, без Loader
}, CARDS_REFRESH_INTERVAL) })
// Polling приглашений (для бейджа) const { start: startInvitesPolling, stop: stopInvitesPolling } = useAutoRefresh('invites', async () => {
if (invitesPollTimer) clearInterval(invitesPollTimer)
invitesPollTimer = setInterval(async () => {
await store.fetchPendingInvitesCount() await store.fetchPendingInvitesCount()
}, INVITES_REFRESH_INTERVAL) })
const startPolling = () => {
startCardsPolling()
startInvitesPolling()
} }
const stopPolling = () => { const stopPolling = () => {
if (pollTimer) { stopCardsPolling()
clearInterval(pollTimer) stopInvitesPolling()
pollTimer = null
}
if (invitesPollTimer) {
clearInterval(invitesPollTimer)
invitesPollTimer = null
}
} }
// ==================== ИНИЦИАЛИЗАЦИЯ ==================== // ==================== ИНИЦИАЛИЗАЦИЯ ====================

View File

@@ -96,13 +96,14 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, onUpdated, nextTick, watch } from 'vue' import { ref, computed, onMounted, onUpdated, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ProjectPanel from '../components/ProjectPanel.vue' import ProjectPanel from '../components/ProjectPanel.vue'
import NotificationCard from '../components/ui/NotificationCard.vue' import NotificationCard from '../components/ui/NotificationCard.vue'
import LogoutButton from '../components/ui/LogoutButton.vue' import LogoutButton from '../components/ui/LogoutButton.vue'
import { useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import { useMobile } from '../composables/useMobile' import { useMobile } from '../composables/useMobile'
import { useAutoRefresh } from '../composables/useAutoRefresh'
import { projectInviteApi, getFullUrl, cardsApi } from '../api' import { projectInviteApi, getFullUrl, cardsApi } from '../api'
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -243,27 +244,21 @@ const refreshIcons = () => {
} }
// Периодическое обновление приглашений // Периодическое обновление приглашений
let refreshInterval = null const { start: startInvitesRefresh, stop: stopInvitesRefresh } = useAutoRefresh('invites', async () => {
const REFRESH_MS = (window.APP_CONFIG?.REFRESH_INTERVALS?.invites || 30) * 1000 // Не обновляем если показывается анимация успеха
if (!showSuccess.value) {
await loadInvites()
}
})
onMounted(() => { onMounted(() => {
loadInvites() loadInvites()
refreshIcons() refreshIcons()
startInvitesRefresh()
// Запускаем периодическое обновление
refreshInterval = setInterval(() => {
// Не обновляем если показывается анимация успеха
if (!showSuccess.value) {
loadInvites()
}
}, REFRESH_MS)
}) })
onUnmounted(() => { onUnmounted(() => {
if (refreshInterval) { stopInvitesRefresh()
clearInterval(refreshInterval)
refreshInterval = null
}
}) })
onUpdated(refreshIcons) onUpdated(refreshIcons)