diff --git a/backend/api/user.php b/backend/api/user.php index 321d9b6..e04bcd0 100644 --- a/backend/api/user.php +++ b/backend/api/user.php @@ -6,6 +6,13 @@ if ($method === 'POST') { $data = RestApi::getInput(); $action = $data['action'] ?? null; + // Получение конфигурации приложения + if ($action === 'get_config') { + RestApi::response(['success' => true, 'data' => [ + 'COLUMN_DONE_ID' => COLUMN_DONE_ID + ]]); + } + // Авторизация if ($action === 'auth_login') { $account = new Account(); diff --git a/backend/app/class/enity/class_task.php b/backend/app/class/enity/class_task.php index 58ac66e..d64513b 100644 --- a/backend/app/class/enity/class_task.php +++ b/backend/app/class/enity/class_task.php @@ -11,6 +11,7 @@ class Task extends BaseEntity { public $order; public $column_id; public $date; + public $date_closed; public $id_account; public $title; public $descript; @@ -99,15 +100,18 @@ class Task extends BaseEntity { return $errors; } - // Проверка что задача существует - $task = Database::get($this->db_name, ['id'], ['id' => $this->id]); + // Проверка что задача существует и получаем текущую колонку + $task = Database::get($this->db_name, ['id', 'column_id'], ['id' => $this->id]); if (!$task) { $this->addError('task', 'Задача не найдена'); return $this->getErrors(); } - // Обновляем в БД - Database::update($this->db_name, [ + $old_column_id = (int)$task['column_id']; + $new_column_id = (int)$this->column_id; + + // Формируем данные для обновления + $update_data = [ 'id_department' => $this->id_department, 'id_label' => $this->id_label, 'order' => $this->order, @@ -117,7 +121,17 @@ class Task extends BaseEntity { 'title' => $this->title, 'descript' => $this->descript ?: null, 'descript_full' => $this->descript_full ?: null - ], [ + ]; + + // Обновляем date_closed при смене колонки + if ($new_column_id === COLUMN_DONE_ID && $old_column_id !== COLUMN_DONE_ID) { + $update_data['date_closed'] = date('Y-m-d H:i:s'); + } elseif ($old_column_id === COLUMN_DONE_ID && $new_column_id !== COLUMN_DONE_ID) { + $update_data['date_closed'] = null; + } + + // Обновляем в БД + Database::update($this->db_name, $update_data, [ 'id' => $this->id ]); @@ -151,7 +165,9 @@ class Task extends BaseEntity { public static function updateOrder($id, $column_id, $to_index) { // Проверка что задача существует - self::check_task($id); + $task = self::check_task($id); + $old_column_id = (int)$task['column_id']; + $new_column_id = (int)$column_id; // Получаем все карточки целевой колонки (кроме перемещаемой) $cards = Database::select('cards_task', ['id', 'order'], [ @@ -165,10 +181,24 @@ class Task extends BaseEntity { // Пересчитываем order для всех карточек foreach ($cards as $index => $card) { - Database::update('cards_task', [ + $update_data = [ 'order' => $index, 'column_id' => $column_id - ], [ + ]; + + // Только для перемещаемой карточки обновляем date_closed + if ($card['id'] == $id) { + // Перемещаем В колонку "Готово" — устанавливаем дату закрытия + if ($new_column_id === COLUMN_DONE_ID && $old_column_id !== COLUMN_DONE_ID) { + $update_data['date_closed'] = date('Y-m-d H:i:s'); + } + // Перемещаем ИЗ колонки "Готово" — обнуляем дату + elseif ($old_column_id === COLUMN_DONE_ID && $new_column_id !== COLUMN_DONE_ID) { + $update_data['date_closed'] = null; + } + } + + Database::update('cards_task', $update_data, [ 'id' => $card['id'] ]); } @@ -193,6 +223,7 @@ class Task extends BaseEntity { 'column_id', 'date', 'date_create', + 'date_closed', 'file_img', 'title', 'descript', @@ -253,13 +284,13 @@ class Task extends BaseEntity { return $task; } - // Установка статуса архивации задачи (только для задач в колонке 4) + // Установка статуса архивации задачи (только для задач в колонке "Готово") public static function setArchive($id, $archive = 1) { // Проверка что задача существует $task = self::check_task($id); - // Архивировать можно только задачи в колонке 4 - if ($archive && $task['column_id'] != 4) { + // Архивировать можно только задачи в колонке "Готово" + if ($archive && (int)$task['column_id'] !== COLUMN_DONE_ID) { RestApi::response([ 'success' => false, 'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"'] diff --git a/backend/app/config.php b/backend/app/config.php index 6c0fc3a..c0f0d2a 100644 --- a/backend/app/config.php +++ b/backend/app/config.php @@ -23,12 +23,15 @@ define('DB_PORT', 3306); define('DB_CHARSET', 'utf8mb4'); + // ID колонки "Готово" (для фиксации date_closed и архивации) + define('COLUMN_DONE_ID', 4); + // Инициализация подключения к БД Database::init(); $routes = [ - '/api/user' => 'api/user.php', - '/api/task' => 'api/task.php', + '/api/user' => __DIR__ . '/../api/user.php', + '/api/task' => __DIR__ . '/../api/task.php', ]; $publicActions = ['auth_login', 'check_session']; diff --git a/front_vue/src/api.js b/front_vue/src/api.js index 50f755c..416ea03 100644 --- a/front_vue/src/api.js +++ b/front_vue/src/api.js @@ -124,4 +124,26 @@ export const taskImageApi = { // ==================== USERS ==================== export const usersApi = { getAll: () => request('/api/user', { credentials: 'include' }) +} + +// ==================== CONFIG ==================== +export const configApi = { + get: () => request('/api/user', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'get_config' }) + }) +} + +// Загрузка конфига с сервера и мерж с window.APP_CONFIG +export const loadServerConfig = async () => { + try { + const result = await configApi.get() + if (result.success && result.data) { + window.APP_CONFIG = { ...window.APP_CONFIG, ...result.data } + } + } catch (error) { + console.error('Ошибка загрузки конфига:', error) + } } \ No newline at end of file diff --git a/front_vue/src/components/Board.vue b/front_vue/src/components/Board.vue index 97cc42f..55fa0fc 100644 --- a/front_vue/src/components/Board.vue +++ b/front_vue/src/components/Board.vue @@ -86,6 +86,7 @@ const columnsWithCards = computed(() => { assignee: card.avatar_img, dueDate: card.date, dateCreate: card.date_create, + dateClosed: card.date_closed, files: card.files || (card.file_img || []).map(f => ({ name: f.name, url: f.url, @@ -133,8 +134,17 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) => const card = localCards.value.find(c => c.id === cardId) if (!card) return + const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID + // Локально обновляем для мгновенного отклика card.column_id = toColumnId + + // Обновляем date_closed при перемещении в/из колонки "Готово" + if (toColumnId === doneColumnId && fromColumnId !== doneColumnId) { + card.date_closed = new Date().toISOString() + } else if (fromColumnId === doneColumnId && toColumnId !== doneColumnId) { + card.date_closed = null + } // Получаем карточки целевой колонки (без перемещаемой) const columnCards = localCards.value diff --git a/front_vue/src/components/Card.vue b/front_vue/src/components/Card.vue index f9e50be..b3cafda 100644 --- a/front_vue/src/components/Card.vue +++ b/front_vue/src/components/Card.vue @@ -52,9 +52,12 @@ Создано: {{ formatDateWithYear(card.dateCreate) }} - + {{ daysLeftText }} + + Закрыто: {{ closedDateText }} + @@ -155,9 +158,28 @@ const isAvatarUrl = (value) => { return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')) } -// Можно ли архивировать (только если колонка 4) +// Форматирование даты закрытия (относительный формат) +const closedDateText = computed(() => { + if (!props.card.dateClosed) return '' + const closed = new Date(props.card.dateClosed) + const today = new Date() + today.setHours(0, 0, 0, 0) + closed.setHours(0, 0, 0, 0) + const daysAgo = Math.round((today - closed) / (1000 * 60 * 60 * 24)) + + if (daysAgo === 0) return 'Сегодня' + if (daysAgo === 1) return 'Вчера' + if (daysAgo >= 2 && daysAgo <= 4) return `${daysAgo} дня назад` + if (daysAgo >= 5 && daysAgo <= 14) return `${daysAgo} дней назад` + return formatDateWithYear(props.card.dateClosed) +}) + +// ID колонки "Готово" из конфига +const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID + +// Можно ли архивировать (только если колонка "Готово") const canArchive = computed(() => { - return Number(props.columnId) === 4 + return Number(props.columnId) === doneColumnId }) const handleArchive = () => { @@ -297,6 +319,11 @@ const handleArchive = () => { color: var(--red); } +.date-closed { + font-size: 11px; + color: var(--green, #00d4aa); +} + .btn-archive-card { display: none; align-items: center; diff --git a/front_vue/src/components/TaskPanel.vue b/front_vue/src/components/TaskPanel.vue index 00e33af..24d6f38 100644 --- a/front_vue/src/components/TaskPanel.vue +++ b/front_vue/src/components/TaskPanel.vue @@ -69,14 +69,12 @@ -
Загрузка...
@@ -181,7 +179,7 @@ import SelectDropdown from './ui/SelectDropdown.vue' import TagsSelect from './ui/TagsSelect.vue' import FileUploader from './ui/FileUploader.vue' import ImagePreview from './ui/ImagePreview.vue' -import { usersApi, taskImageApi, getFullUrl } from '../api' +import { taskImageApi, getFullUrl } from '../api' const props = defineProps({ show: Boolean, @@ -194,14 +192,16 @@ const props = defineProps({ labels: { type: Array, default: () => [] + }, + users: { + type: Array, + default: () => [] } }) const emit = defineEmits(['close', 'save', 'delete', 'archive']) const isNew = ref(true) -const users = ref([]) -const usersLoading = ref(false) const isSaving = ref(false) const form = reactive({ @@ -237,7 +237,7 @@ const labelOptions = computed(() => { // Преобразование users в формат для SelectDropdown const userOptions = computed(() => { - return users.value.map(user => ({ + return props.users.map(user => ({ value: user.id, label: user.name, subtitle: user.telegram, @@ -309,23 +309,9 @@ const cancelClose = () => { showUnsavedDialog.value = false } -const fetchUsers = async () => { - usersLoading.value = true - try { - const data = await usersApi.getAll() - if (data.success) { - users.value = data.data - } - } catch (error) { - console.error('Ошибка загрузки пользователей:', error) - } finally { - usersLoading.value = false - } -} - const getAvatarByUserId = (userId) => { if (!userId) return null - const user = users.value.find(u => u.id === userId) + const user = props.users.find(u => u.id === userId) return user ? user.avatar_url : null } @@ -356,8 +342,6 @@ watch(() => props.show, async (newVal) => { isNew.value = !props.card clearFiles() - await fetchUsers() - if (props.card) { form.title = props.card.title || '' form.description = props.card.description || '' @@ -456,9 +440,9 @@ const handleDelete = () => { showDeleteDialog.value = true } -// Можно ли архивировать (только если колонка 4) +// Можно ли архивировать (только если колонка "Готово") const canArchive = computed(() => { - return Number(props.columnId) === 4 + return Number(props.columnId) === window.APP_CONFIG.COLUMN_DONE_ID }) const handleArchive = () => { @@ -593,11 +577,6 @@ onUpdated(refreshIcons) height: 10px; } -.users-loading { - color: var(--text-muted); - font-size: 13px; - padding: 12px 0; -} .btn-icon.btn-delete { border: 1px solid var(--red); diff --git a/front_vue/src/components/ui/SelectDropdown.vue b/front_vue/src/components/ui/SelectDropdown.vue index f23dfe8..d6dcdfc 100644 --- a/front_vue/src/components/ui/SelectDropdown.vue +++ b/front_vue/src/components/ui/SelectDropdown.vue @@ -27,7 +27,7 @@ ref="searchInputRef" @click.stop > -