Добавление логики
1. Получения конфигурациия с бека 2. Время закрытия задачи 3. Изменение фронта под новую локигу конфигурации 4. Обновление структуры бд
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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' => 'Архивировать можно только задачи из колонки "Готово"']
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -125,3 +125,25 @@ export const taskImageApi = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,9 +134,18 @@ 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
|
||||
.filter(c => c.column_id === toColumnId && c.id !== cardId)
|
||||
|
||||
@@ -52,9 +52,12 @@
|
||||
<span v-if="card.dateCreate" class="date-create">
|
||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||
</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 }}
|
||||
</span>
|
||||
<span v-if="Number(columnId) === doneColumnId && card.dateClosed" class="date-closed">
|
||||
Закрыто: {{ closedDateText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
@@ -69,14 +69,12 @@
|
||||
|
||||
<FormField label="Исполнитель">
|
||||
<SelectDropdown
|
||||
v-if="!usersLoading"
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
searchable
|
||||
placeholder="Без исполнителя"
|
||||
empty-label="Без исполнителя"
|
||||
/>
|
||||
<div v-else class="users-loading">Загрузка...</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
ref="searchInputRef"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dropdown-list">
|
||||
<div class="dropdown-list" ref="listRef">
|
||||
<!-- Опция "не выбрано" -->
|
||||
<button
|
||||
v-if="allowEmpty"
|
||||
@@ -114,6 +114,7 @@ const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dropdownRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
const listRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const openUp = ref(false)
|
||||
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
|
||||
const toggleDropdown = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
updatePosition()
|
||||
scrollToActive()
|
||||
searchInputRef.value?.focus()
|
||||
refreshIcons()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainApp from './views/MainApp.vue'
|
||||
import LoginPage from './views/LoginPage.vue'
|
||||
import TeamPage from './views/TeamPage.vue'
|
||||
import { authApi } from './api'
|
||||
import { authApi, loadServerConfig } from './api'
|
||||
|
||||
// Флаг загрузки конфига (один раз за сессию)
|
||||
let configLoaded = false
|
||||
|
||||
// Проверка авторизации
|
||||
const checkAuth = async () => {
|
||||
@@ -50,6 +53,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
// Уже авторизован — на главную
|
||||
next('/')
|
||||
} else {
|
||||
// Загружаем конфиг с сервера один раз для защищённых страниц
|
||||
if (to.meta.requiresAuth && isAuth && !configLoaded) {
|
||||
await loadServerConfig()
|
||||
configLoaded = true
|
||||
}
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
:column-id="editingColumnId"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
:users="users"
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
@@ -84,7 +85,7 @@ import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Board from '../components/Board.vue'
|
||||
import TaskPanel from '../components/TaskPanel.vue'
|
||||
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
||||
import { departmentsApi, labelsApi, columnsApi, cardsApi, usersApi } from '../api'
|
||||
|
||||
// Активный фильтр по отделу (null = все)
|
||||
// Восстанавливаем из localStorage
|
||||
@@ -106,21 +107,24 @@ const departments = ref([])
|
||||
const labels = ref([])
|
||||
const columns = ref([])
|
||||
const cards = ref([])
|
||||
const users = ref([])
|
||||
|
||||
// Загрузка всех данных из API параллельно
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [departmentsData, labelsData, columnsData, cardsData] = await Promise.all([
|
||||
const [departmentsData, labelsData, columnsData, cardsData, usersData] = await Promise.all([
|
||||
departmentsApi.getAll(),
|
||||
labelsApi.getAll(),
|
||||
columnsApi.getAll(),
|
||||
cardsApi.getAll()
|
||||
cardsApi.getAll(),
|
||||
usersApi.getAll()
|
||||
])
|
||||
|
||||
if (departmentsData.success) departments.value = departmentsData.data
|
||||
if (labelsData.success) labels.value = labelsData.data
|
||||
if (columnsData.success) columns.value = columnsData.data
|
||||
if (cardsData.success) cards.value = cardsData.data
|
||||
if (usersData.success) users.value = usersData.data
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Target Server Version : 90200 (9.2.0)
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 13/01/2026 08:18:18
|
||||
Date: 13/01/2026 09:07:01
|
||||
*/
|
||||
|
||||
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,
|
||||
`user_agent` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
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
|
||||
@@ -61,6 +61,7 @@ CREATE TABLE `cards_task` (
|
||||
`archive` tinyint NULL DEFAULT NULL,
|
||||
`date` datetime NULL DEFAULT NULL,
|
||||
`date_create` datetime NULL DEFAULT NULL,
|
||||
`date_closed` datetime NULL DEFAULT NULL,
|
||||
`file_img` json 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,
|
||||
|
||||
Reference in New Issue
Block a user