1
0

Добавление логики

1. Получения конфигурациия с бека
2. Время закрытия задачи
3. Изменение фронта под новую локигу конфигурации
4. Обновление структуры бд
This commit is contained in:
2026-01-13 09:11:56 +07:00
parent 7449b46091
commit 2d27abc48a
11 changed files with 157 additions and 53 deletions

View File

@@ -6,6 +6,13 @@ if ($method === 'POST') {
$data = RestApi::getInput(); $data = RestApi::getInput();
$action = $data['action'] ?? null; $action = $data['action'] ?? null;
// Получение конфигурации приложения
if ($action === 'get_config') {
RestApi::response(['success' => true, 'data' => [
'COLUMN_DONE_ID' => COLUMN_DONE_ID
]]);
}
// Авторизация // Авторизация
if ($action === 'auth_login') { if ($action === 'auth_login') {
$account = new Account(); $account = new Account();

View File

@@ -11,6 +11,7 @@ class Task extends BaseEntity {
public $order; public $order;
public $column_id; public $column_id;
public $date; public $date;
public $date_closed;
public $id_account; public $id_account;
public $title; public $title;
public $descript; public $descript;
@@ -99,15 +100,18 @@ class Task extends BaseEntity {
return $errors; 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) { if (!$task) {
$this->addError('task', 'Задача не найдена'); $this->addError('task', 'Задача не найдена');
return $this->getErrors(); return $this->getErrors();
} }
// Обновляем в БД $old_column_id = (int)$task['column_id'];
Database::update($this->db_name, [ $new_column_id = (int)$this->column_id;
// Формируем данные для обновления
$update_data = [
'id_department' => $this->id_department, 'id_department' => $this->id_department,
'id_label' => $this->id_label, 'id_label' => $this->id_label,
'order' => $this->order, 'order' => $this->order,
@@ -117,7 +121,17 @@ class Task extends BaseEntity {
'title' => $this->title, 'title' => $this->title,
'descript' => $this->descript ?: null, 'descript' => $this->descript ?: null,
'descript_full' => $this->descript_full ?: 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 'id' => $this->id
]); ]);
@@ -151,7 +165,9 @@ class Task extends BaseEntity {
public static function updateOrder($id, $column_id, $to_index) { 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'], [ $cards = Database::select('cards_task', ['id', 'order'], [
@@ -165,10 +181,24 @@ class Task extends BaseEntity {
// Пересчитываем order для всех карточек // Пересчитываем order для всех карточек
foreach ($cards as $index => $card) { foreach ($cards as $index => $card) {
Database::update('cards_task', [ $update_data = [
'order' => $index, 'order' => $index,
'column_id' => $column_id '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'] 'id' => $card['id']
]); ]);
} }
@@ -193,6 +223,7 @@ class Task extends BaseEntity {
'column_id', 'column_id',
'date', 'date',
'date_create', 'date_create',
'date_closed',
'file_img', 'file_img',
'title', 'title',
'descript', 'descript',
@@ -253,13 +284,13 @@ class Task extends BaseEntity {
return $task; return $task;
} }
// Установка статуса архивации задачи (только для задач в колонке 4) // Установка статуса архивации задачи (только для задач в колонке "Готово")
public static function setArchive($id, $archive = 1) { public static function setArchive($id, $archive = 1) {
// Проверка что задача существует // Проверка что задача существует
$task = self::check_task($id); $task = self::check_task($id);
// Архивировать можно только задачи в колонке 4 // Архивировать можно только задачи в колонке "Готово"
if ($archive && $task['column_id'] != 4) { if ($archive && (int)$task['column_id'] !== COLUMN_DONE_ID) {
RestApi::response([ RestApi::response([
'success' => false, 'success' => false,
'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"'] 'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"']

View File

@@ -23,12 +23,15 @@
define('DB_PORT', 3306); define('DB_PORT', 3306);
define('DB_CHARSET', 'utf8mb4'); define('DB_CHARSET', 'utf8mb4');
// ID колонки "Готово" (для фиксации date_closed и архивации)
define('COLUMN_DONE_ID', 4);
// Инициализация подключения к БД // Инициализация подключения к БД
Database::init(); Database::init();
$routes = [ $routes = [
'/api/user' => 'api/user.php', '/api/user' => __DIR__ . '/../api/user.php',
'/api/task' => 'api/task.php', '/api/task' => __DIR__ . '/../api/task.php',
]; ];
$publicActions = ['auth_login', 'check_session']; $publicActions = ['auth_login', 'check_session'];

View File

@@ -125,3 +125,25 @@ export const taskImageApi = {
export const usersApi = { export const usersApi = {
getAll: () => request('/api/user', { credentials: 'include' }) 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)
}
}

View File

@@ -86,6 +86,7 @@ const columnsWithCards = computed(() => {
assignee: card.avatar_img, assignee: card.avatar_img,
dueDate: card.date, dueDate: card.date,
dateCreate: card.date_create, dateCreate: card.date_create,
dateClosed: card.date_closed,
files: card.files || (card.file_img || []).map(f => ({ files: card.files || (card.file_img || []).map(f => ({
name: f.name, name: f.name,
url: f.url, url: f.url,
@@ -133,9 +134,18 @@ const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) =>
const card = localCards.value.find(c => c.id === cardId) const card = localCards.value.find(c => c.id === cardId)
if (!card) return if (!card) return
const doneColumnId = window.APP_CONFIG.COLUMN_DONE_ID
// Локально обновляем для мгновенного отклика // Локально обновляем для мгновенного отклика
card.column_id = toColumnId 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 const columnCards = localCards.value
.filter(c => c.column_id === toColumnId && c.id !== cardId) .filter(c => c.column_id === toColumnId && c.id !== cardId)

View File

@@ -52,9 +52,12 @@
<span v-if="card.dateCreate" class="date-create"> <span v-if="card.dateCreate" class="date-create">
Создано: {{ formatDateWithYear(card.dateCreate) }} Создано: {{ formatDateWithYear(card.dateCreate) }}
</span> </span>
<span v-if="card.dueDate && Number(columnId) !== 4" class="due-date" :class="dueDateStatus"> <span v-if="card.dueDate && Number(columnId) !== doneColumnId" class="due-date" :class="dueDateStatus">
{{ daysLeftText }} {{ daysLeftText }}
</span> </span>
<span v-if="Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
Закрыто: {{ closedDateText }}
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -155,9 +158,28 @@ const isAvatarUrl = (value) => {
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')) 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(() => { const canArchive = computed(() => {
return Number(props.columnId) === 4 return Number(props.columnId) === doneColumnId
}) })
const handleArchive = () => { const handleArchive = () => {
@@ -297,6 +319,11 @@ const handleArchive = () => {
color: var(--red); color: var(--red);
} }
.date-closed {
font-size: 11px;
color: var(--green, #00d4aa);
}
.btn-archive-card { .btn-archive-card {
display: none; display: none;
align-items: center; align-items: center;

View File

@@ -69,14 +69,12 @@
<FormField label="Исполнитель"> <FormField label="Исполнитель">
<SelectDropdown <SelectDropdown
v-if="!usersLoading"
v-model="form.userId" v-model="form.userId"
:options="userOptions" :options="userOptions"
searchable searchable
placeholder="Без исполнителя" placeholder="Без исполнителя"
empty-label="Без исполнителя" empty-label="Без исполнителя"
/> />
<div v-else class="users-loading">Загрузка...</div>
</FormField> </FormField>
</div> </div>
@@ -181,7 +179,7 @@ import SelectDropdown from './ui/SelectDropdown.vue'
import TagsSelect from './ui/TagsSelect.vue' import TagsSelect from './ui/TagsSelect.vue'
import FileUploader from './ui/FileUploader.vue' import FileUploader from './ui/FileUploader.vue'
import ImagePreview from './ui/ImagePreview.vue' import ImagePreview from './ui/ImagePreview.vue'
import { usersApi, taskImageApi, getFullUrl } from '../api' import { taskImageApi, getFullUrl } from '../api'
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
@@ -194,14 +192,16 @@ const props = defineProps({
labels: { labels: {
type: Array, type: Array,
default: () => [] default: () => []
},
users: {
type: Array,
default: () => []
} }
}) })
const emit = defineEmits(['close', 'save', 'delete', 'archive']) const emit = defineEmits(['close', 'save', 'delete', 'archive'])
const isNew = ref(true) const isNew = ref(true)
const users = ref([])
const usersLoading = ref(false)
const isSaving = ref(false) const isSaving = ref(false)
const form = reactive({ const form = reactive({
@@ -237,7 +237,7 @@ const labelOptions = computed(() => {
// Преобразование users в формат для SelectDropdown // Преобразование users в формат для SelectDropdown
const userOptions = computed(() => { const userOptions = computed(() => {
return users.value.map(user => ({ return props.users.map(user => ({
value: user.id, value: user.id,
label: user.name, label: user.name,
subtitle: user.telegram, subtitle: user.telegram,
@@ -309,23 +309,9 @@ const cancelClose = () => {
showUnsavedDialog.value = false 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) => { const getAvatarByUserId = (userId) => {
if (!userId) return null 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 return user ? user.avatar_url : null
} }
@@ -356,8 +342,6 @@ watch(() => props.show, async (newVal) => {
isNew.value = !props.card isNew.value = !props.card
clearFiles() clearFiles()
await fetchUsers()
if (props.card) { if (props.card) {
form.title = props.card.title || '' form.title = props.card.title || ''
form.description = props.card.description || '' form.description = props.card.description || ''
@@ -456,9 +440,9 @@ const handleDelete = () => {
showDeleteDialog.value = true showDeleteDialog.value = true
} }
// Можно ли архивировать (только если колонка 4) // Можно ли архивировать (только если колонка "Готово")
const canArchive = computed(() => { const canArchive = computed(() => {
return Number(props.columnId) === 4 return Number(props.columnId) === window.APP_CONFIG.COLUMN_DONE_ID
}) })
const handleArchive = () => { const handleArchive = () => {
@@ -593,11 +577,6 @@ onUpdated(refreshIcons)
height: 10px; height: 10px;
} }
.users-loading {
color: var(--text-muted);
font-size: 13px;
padding: 12px 0;
}
.btn-icon.btn-delete { .btn-icon.btn-delete {
border: 1px solid var(--red); border: 1px solid var(--red);

View File

@@ -27,7 +27,7 @@
ref="searchInputRef" ref="searchInputRef"
@click.stop @click.stop
> >
<div class="dropdown-list"> <div class="dropdown-list" ref="listRef">
<!-- Опция "не выбрано" --> <!-- Опция "не выбрано" -->
<button <button
v-if="allowEmpty" v-if="allowEmpty"
@@ -114,6 +114,7 @@ const emit = defineEmits(['update:modelValue', 'change'])
const dropdownRef = ref(null) const dropdownRef = ref(null)
const searchInputRef = ref(null) const searchInputRef = ref(null)
const listRef = ref(null)
const isOpen = ref(false) const isOpen = ref(false)
const openUp = ref(false) const openUp = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
@@ -134,12 +135,23 @@ const filteredOptions = computed(() => {
) )
}) })
// Прокрутка к активному элементу
const scrollToActive = () => {
if (listRef.value && props.modelValue) {
const activeItem = listRef.value.querySelector('.dropdown-item.active')
if (activeItem) {
activeItem.scrollIntoView({ block: 'center', behavior: 'instant' })
}
}
}
// Переключение dropdown // Переключение dropdown
const toggleDropdown = async () => { const toggleDropdown = async () => {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value) { if (isOpen.value) {
await nextTick() await nextTick()
updatePosition() updatePosition()
scrollToActive()
searchInputRef.value?.focus() searchInputRef.value?.focus()
refreshIcons() refreshIcons()
} }

View File

@@ -2,7 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
import MainApp from './views/MainApp.vue' import MainApp from './views/MainApp.vue'
import LoginPage from './views/LoginPage.vue' import LoginPage from './views/LoginPage.vue'
import TeamPage from './views/TeamPage.vue' import TeamPage from './views/TeamPage.vue'
import { authApi } from './api' import { authApi, loadServerConfig } from './api'
// Флаг загрузки конфига (один раз за сессию)
let configLoaded = false
// Проверка авторизации // Проверка авторизации
const checkAuth = async () => { const checkAuth = async () => {
@@ -50,6 +53,11 @@ router.beforeEach(async (to, from, next) => {
// Уже авторизован — на главную // Уже авторизован — на главную
next('/') next('/')
} else { } else {
// Загружаем конфиг с сервера один раз для защищённых страниц
if (to.meta.requiresAuth && isAuth && !configLoaded) {
await loadServerConfig()
configLoaded = true
}
next() next()
} }
}) })

View File

@@ -70,6 +70,7 @@
:column-id="editingColumnId" :column-id="editingColumnId"
:departments="departments" :departments="departments"
:labels="labels" :labels="labels"
:users="users"
@close="closePanel" @close="closePanel"
@save="handleSaveTask" @save="handleSaveTask"
@delete="handleDeleteTask" @delete="handleDeleteTask"
@@ -84,7 +85,7 @@ import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue' import Header from '../components/Header.vue'
import Board from '../components/Board.vue' import Board from '../components/Board.vue'
import TaskPanel from '../components/TaskPanel.vue' import TaskPanel from '../components/TaskPanel.vue'
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api' import { departmentsApi, labelsApi, columnsApi, cardsApi, usersApi } from '../api'
// Активный фильтр по отделу (null = все) // Активный фильтр по отделу (null = все)
// Восстанавливаем из localStorage // Восстанавливаем из localStorage
@@ -106,21 +107,24 @@ const departments = ref([])
const labels = ref([]) const labels = ref([])
const columns = ref([]) const columns = ref([])
const cards = ref([]) const cards = ref([])
const users = ref([])
// Загрузка всех данных из API параллельно // Загрузка всех данных из API параллельно
const fetchData = async () => { const fetchData = async () => {
try { try {
const [departmentsData, labelsData, columnsData, cardsData] = await Promise.all([ const [departmentsData, labelsData, columnsData, cardsData, usersData] = await Promise.all([
departmentsApi.getAll(), departmentsApi.getAll(),
labelsApi.getAll(), labelsApi.getAll(),
columnsApi.getAll(), columnsApi.getAll(),
cardsApi.getAll() cardsApi.getAll(),
usersApi.getAll()
]) ])
if (departmentsData.success) departments.value = departmentsData.data if (departmentsData.success) departments.value = departmentsData.data
if (labelsData.success) labels.value = labelsData.data if (labelsData.success) labels.value = labelsData.data
if (columnsData.success) columns.value = columnsData.data if (columnsData.success) columns.value = columnsData.data
if (cardsData.success) cards.value = cardsData.data if (cardsData.success) cards.value = cardsData.data
if (usersData.success) users.value = usersData.data
} catch (error) { } catch (error) {
console.error('Ошибка загрузки данных:', error) console.error('Ошибка загрузки данных:', error)
} }

View File

@@ -11,7 +11,7 @@
Target Server Version : 90200 (9.2.0) Target Server Version : 90200 (9.2.0)
File Encoding : 65001 File Encoding : 65001
Date: 13/01/2026 08:18:18 Date: 13/01/2026 09:07:01
*/ */
SET NAMES utf8mb4; SET NAMES utf8mb4;
@@ -45,7 +45,7 @@ CREATE TABLE `accounts_session` (
`ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 26 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; ) ENGINE = InnoDB AUTO_INCREMENT = 28 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ---------------------------- -- ----------------------------
-- Table structure for cards_task -- Table structure for cards_task
@@ -61,6 +61,7 @@ CREATE TABLE `cards_task` (
`archive` tinyint NULL DEFAULT NULL, `archive` tinyint NULL DEFAULT NULL,
`date` datetime NULL DEFAULT NULL, `date` datetime NULL DEFAULT NULL,
`date_create` datetime NULL DEFAULT NULL, `date_create` datetime NULL DEFAULT NULL,
`date_closed` datetime NULL DEFAULT NULL,
`file_img` json NULL, `file_img` json NULL,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
`descript` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL, `descript` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,