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

@@ -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)
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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()
}

View File

@@ -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()
}
})

View File

@@ -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)
}