Обновление бека+фронт
MVP версия - которая уже готова к работе
This commit is contained in:
@@ -31,37 +31,77 @@ export const authApi = {
|
||||
|
||||
// ==================== DEPARTMENTS ====================
|
||||
export const departmentsApi = {
|
||||
getAll: () => request('/departments', { credentials: 'include' })
|
||||
getAll: () => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_departments' })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== LABELS ====================
|
||||
export const labelsApi = {
|
||||
getAll: () => request('/labels', { credentials: 'include' })
|
||||
getAll: () => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_labels' })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== COLUMNS ====================
|
||||
export const columnsApi = {
|
||||
getAll: () => request('/columns', { credentials: 'include' })
|
||||
getAll: () => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_columns' })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== CARDS ====================
|
||||
export const cardsApi = {
|
||||
getAll: () => request('/cards', { credentials: 'include' }),
|
||||
create: (data) => request('/cards', {
|
||||
getAll: () => request('/api/task', { credentials: 'include' }),
|
||||
updateOrder: (id, column_id, to_index) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update_order', id, column_id, to_index })
|
||||
}),
|
||||
create: (data) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify({ action: 'create', ...data })
|
||||
}),
|
||||
update: (id, data) => request(`/cards/${id}`, {
|
||||
method: 'PUT',
|
||||
update: (data) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
body: JSON.stringify({ action: 'update', ...data })
|
||||
}),
|
||||
delete: (id) => request(`/cards/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
delete: (id) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== TASK IMAGES ====================
|
||||
export const taskImageApi = {
|
||||
upload: (task_id, file_data, file_name) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'upload_image', task_id, file_data, file_name })
|
||||
}),
|
||||
// Принимает строку (один файл) или массив (несколько файлов)
|
||||
delete: (task_id, file_names) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete_image', task_id, file_names })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||||
import Column from './Column.vue'
|
||||
import { cardsApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
activeDepartment: Number,
|
||||
@@ -68,18 +69,19 @@ watch(() => props.cards, (newCards) => {
|
||||
const columnsWithCards = computed(() => {
|
||||
return props.columns.map(col => ({
|
||||
id: col.id,
|
||||
title: col.name,
|
||||
title: col.name_columns,
|
||||
color: col.color,
|
||||
cards: localCards.value
|
||||
.filter(card => card.column_id === col.id)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(card => ({
|
||||
id: card.id_card,
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
description: card.descript,
|
||||
details: card.descript_full,
|
||||
departmentId: card.id_department,
|
||||
labelId: card.id_label,
|
||||
accountId: card.id_account,
|
||||
assignee: card.avatar_img,
|
||||
dueDate: card.date,
|
||||
dateCreate: card.date_create,
|
||||
@@ -126,35 +128,36 @@ watch([filteredTotalTasks, inProgressTasks, completedTasks], () => {
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDropCard = ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||||
const card = localCards.value.find(c => c.id_card === cardId)
|
||||
const handleDropCard = async ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||||
const card = localCards.value.find(c => c.id === cardId)
|
||||
if (!card) return
|
||||
|
||||
// Меняем колонку
|
||||
// Локально обновляем для мгновенного отклика
|
||||
card.column_id = toColumnId
|
||||
|
||||
// Получаем карточки целевой колонки (без перемещаемой)
|
||||
const columnCards = localCards.value
|
||||
.filter(c => c.column_id === toColumnId && c.id_card !== cardId)
|
||||
.filter(c => c.column_id === toColumnId && c.id !== cardId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Вставляем карточку в нужную позицию и пересчитываем order
|
||||
// Вставляем карточку в нужную позицию и пересчитываем order локально
|
||||
columnCards.splice(toIndex, 0, card)
|
||||
columnCards.forEach((c, idx) => {
|
||||
c.order = idx
|
||||
})
|
||||
|
||||
// TODO: отправить изменение на сервер
|
||||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||||
}
|
||||
|
||||
// Генератор id для новых карточек
|
||||
let nextCardId = 100
|
||||
|
||||
// Методы для модалки
|
||||
const saveTask = (taskData, columnId) => {
|
||||
const saveTask = async (taskData, columnId) => {
|
||||
if (taskData.id) {
|
||||
// Редактирование существующей карточки
|
||||
const card = localCards.value.find(c => c.id_card === taskData.id)
|
||||
const card = localCards.value.find(c => c.id === taskData.id)
|
||||
if (card) {
|
||||
card.title = taskData.title
|
||||
card.descript = taskData.description
|
||||
@@ -162,8 +165,23 @@ const saveTask = (taskData, columnId) => {
|
||||
card.id_department = taskData.departmentId
|
||||
card.id_label = taskData.labelId
|
||||
card.date = taskData.dueDate
|
||||
card.id_account = taskData.accountId
|
||||
card.avatar_img = taskData.assignee
|
||||
card.files = taskData.files || []
|
||||
|
||||
// Отправляем на сервер
|
||||
await cardsApi.update({
|
||||
id: taskData.id,
|
||||
id_department: taskData.departmentId,
|
||||
id_label: taskData.labelId,
|
||||
id_account: taskData.accountId,
|
||||
column_id: card.column_id,
|
||||
order: card.order,
|
||||
date: taskData.dueDate,
|
||||
title: taskData.title,
|
||||
descript: taskData.description,
|
||||
descript_full: taskData.details
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Создание новой карточки (в конец колонки)
|
||||
@@ -172,32 +190,52 @@ const saveTask = (taskData, columnId) => {
|
||||
? Math.max(...columnCards.map(c => c.order)) + 1
|
||||
: 0
|
||||
|
||||
localCards.value.push({
|
||||
id_card: nextCardId++,
|
||||
// Отправляем на сервер
|
||||
const result = await cardsApi.create({
|
||||
id_department: taskData.departmentId,
|
||||
id_label: taskData.labelId,
|
||||
id_account: taskData.accountId,
|
||||
column_id: columnId,
|
||||
order: maxOrder,
|
||||
date: taskData.dueDate,
|
||||
title: taskData.title,
|
||||
descript: taskData.description,
|
||||
descript_full: taskData.details,
|
||||
avatar_img: taskData.assignee,
|
||||
column_id: columnId,
|
||||
date: taskData.dueDate,
|
||||
date_create: new Date().toISOString().split('T')[0],
|
||||
order: maxOrder,
|
||||
files: taskData.files || []
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// Добавляем локально с ID от сервера
|
||||
localCards.value.push({
|
||||
id: parseInt(result.id),
|
||||
id_department: taskData.departmentId,
|
||||
id_label: taskData.labelId,
|
||||
id_account: taskData.accountId,
|
||||
title: taskData.title,
|
||||
descript: taskData.description,
|
||||
descript_full: taskData.details,
|
||||
avatar_img: taskData.assignee,
|
||||
column_id: columnId,
|
||||
date: taskData.dueDate,
|
||||
date_create: new Date().toISOString().split('T')[0],
|
||||
order: maxOrder,
|
||||
files: result.files || []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: отправить на сервер
|
||||
}
|
||||
|
||||
const deleteTask = (cardId, columnId) => {
|
||||
const index = localCards.value.findIndex(c => c.id_card === cardId)
|
||||
if (index !== -1) {
|
||||
localCards.value.splice(index, 1)
|
||||
}
|
||||
const deleteTask = async (cardId, columnId) => {
|
||||
// Удаляем на сервере
|
||||
const result = await cardsApi.delete(cardId)
|
||||
|
||||
// TODO: отправить на сервер
|
||||
if (result.success) {
|
||||
// Удаляем локально
|
||||
const index = localCards.value.findIndex(c => c.id === cardId)
|
||||
if (index !== -1) {
|
||||
localCards.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ saveTask, deleteTask })
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
class="tag"
|
||||
:style="{ color: cardDepartment.color }"
|
||||
>
|
||||
{{ cardDepartment.name }}
|
||||
{{ cardDepartment.name_departments }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
:style="{ '--tag-color': dept.color }"
|
||||
@click="selectDepartment(dept.id)"
|
||||
>
|
||||
{{ dept.name }}
|
||||
{{ dept.name_departments }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@
|
||||
@click="selectLabel(label.id)"
|
||||
>
|
||||
<span class="priority-icon">{{ label.icon }}</span>
|
||||
{{ label.name }}
|
||||
{{ label.name_labels }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
<!-- Пустая зона drag & drop (когда нет файлов) -->
|
||||
<div
|
||||
v-if="attachedFiles.length === 0"
|
||||
v-if="visibleFiles.length === 0"
|
||||
class="file-dropzone"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@@ -179,6 +179,7 @@
|
||||
v-for="(file, index) in attachedFiles"
|
||||
:key="file.name + '-' + file.size"
|
||||
class="file-preview-item"
|
||||
v-show="!file.toDelete"
|
||||
>
|
||||
<div class="file-actions">
|
||||
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
|
||||
@@ -189,7 +190,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-thumbnail" @click="openImagePreview(file)">
|
||||
<img :src="file.preview" :alt="file.name">
|
||||
<img :src="getFullUrl(file.preview)" :alt="file.name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -216,8 +217,9 @@
|
||||
</button>
|
||||
<div class="footer-right">
|
||||
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
||||
<button class="btn-save" @click="handleSave" :disabled="!form.title.trim()">
|
||||
{{ isNew ? 'Создать' : 'Сохранить' }}
|
||||
<button class="btn-save" @click="handleSave" :disabled="!form.title.trim() || !form.departmentId || isSaving">
|
||||
<span v-if="isSaving" class="btn-loader"></span>
|
||||
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,7 +274,7 @@
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<img :src="previewImage.preview" :alt="previewImage.name" class="image-preview-full">
|
||||
<img :src="getFullUrl(previewImage.preview)" :alt="previewImage.name" class="image-preview-full">
|
||||
<div class="image-preview-info">
|
||||
<span class="preview-name">{{ previewImage.name }}</span>
|
||||
<span class="preview-size">{{ formatFileSize(previewImage.size) }}</span>
|
||||
@@ -288,7 +290,7 @@
|
||||
import { ref, reactive, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import { usersApi } from '../api'
|
||||
import { usersApi, taskImageApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -309,6 +311,7 @@ const emit = defineEmits(['close', 'save', 'delete'])
|
||||
const isNew = ref(true)
|
||||
const users = ref([])
|
||||
const usersLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
@@ -334,6 +337,19 @@ const isDragging = ref(false)
|
||||
const fileError = ref('')
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg']
|
||||
const maxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
const API_BASE = window.APP_CONFIG?.API_BASE || ''
|
||||
|
||||
// Формирование полного URL (добавляет домен к относительным путям)
|
||||
const getFullUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
return API_BASE + url
|
||||
}
|
||||
|
||||
// Видимые файлы (без помеченных на удаление)
|
||||
const visibleFiles = computed(() => attachedFiles.value.filter(f => !f.toDelete))
|
||||
|
||||
// Просмотр изображения
|
||||
const previewImage = ref(null)
|
||||
@@ -536,8 +552,8 @@ watch(() => props.show, async (newVal) => {
|
||||
form.departmentId = props.card.departmentId || null
|
||||
form.labelId = props.card.labelId || null
|
||||
form.dueDate = props.card.dueDate || ''
|
||||
// Находим userId по avatar_url
|
||||
form.userId = findUserIdByAvatar(props.card.assignee)
|
||||
// Используем accountId напрямую
|
||||
form.userId = props.card.accountId || null
|
||||
|
||||
// Загружаем существующие файлы если есть
|
||||
if (props.card.files && props.card.files.length > 0) {
|
||||
@@ -545,7 +561,8 @@ watch(() => props.show, async (newVal) => {
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
preview: f.data || f.url
|
||||
preview: f.data || f.url,
|
||||
isNew: false // Файл уже на сервере
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
@@ -562,8 +579,45 @@ watch(() => props.show, async (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSaving.value = true
|
||||
fileError.value = ''
|
||||
|
||||
if (props.card?.id) {
|
||||
|
||||
// Загружаем новые файлы
|
||||
const newFiles = attachedFiles.value.filter(f => f.isNew && !f.toDelete)
|
||||
for (const file of newFiles) {
|
||||
const result = await taskImageApi.upload(props.card.id, file.preview, file.name)
|
||||
if (result.success) {
|
||||
file.isNew = false
|
||||
file.name = result.file.name
|
||||
file.preview = result.file.url
|
||||
} else {
|
||||
fileError.value = result.errors?.file || 'Ошибка загрузки файла'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем помеченные файлы
|
||||
const filesToDelete = attachedFiles.value.filter(f => f.toDelete && !f.isNew)
|
||||
if (filesToDelete.length > 0) {
|
||||
const fileNames = filesToDelete.map(f => f.name)
|
||||
const result = await taskImageApi.delete(props.card.id, fileNames)
|
||||
if (!result.success) {
|
||||
fileError.value = result.errors?.file || 'Ошибка удаления файла'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Убираем удалённые из массива
|
||||
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
@@ -571,15 +625,20 @@ const handleSave = () => {
|
||||
departmentId: form.departmentId,
|
||||
labelId: form.labelId,
|
||||
dueDate: form.dueDate,
|
||||
accountId: form.userId,
|
||||
assignee: getAvatarByUserId(form.userId),
|
||||
id: props.card?.id,
|
||||
files: attachedFiles.value.map(f => ({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
data: f.preview
|
||||
}))
|
||||
files: attachedFiles.value
|
||||
.filter(f => !f.toDelete)
|
||||
.map(f => ({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
data: f.preview
|
||||
}))
|
||||
})
|
||||
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -653,7 +712,8 @@ const processFiles = (files) => {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview: e.target.result
|
||||
preview: e.target.result,
|
||||
isNew: true // Флаг что файл новый и не загружен на сервер
|
||||
})
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
@@ -669,7 +729,15 @@ const removeFile = (index) => {
|
||||
|
||||
const confirmDeleteFile = () => {
|
||||
if (fileToDeleteIndex.value !== null) {
|
||||
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
|
||||
const file = attachedFiles.value[fileToDeleteIndex.value]
|
||||
|
||||
if (file.isNew) {
|
||||
// Новый файл — просто удаляем из массива
|
||||
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
|
||||
} else {
|
||||
// Существующий файл — помечаем для удаления при сохранении
|
||||
file.toDelete = true
|
||||
}
|
||||
}
|
||||
showDeleteFileDialog.value = false
|
||||
fileToDeleteIndex.value = null
|
||||
@@ -709,6 +777,7 @@ const deleteFromPreview = () => {
|
||||
}
|
||||
|
||||
const downloadFile = async (file) => {
|
||||
const url = getFullUrl(file.preview)
|
||||
try {
|
||||
// Если это data URL (base64) - скачиваем напрямую
|
||||
if (file.preview.startsWith('data:')) {
|
||||
@@ -720,21 +789,21 @@ const downloadFile = async (file) => {
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
// Для внешних URL - загружаем как blob
|
||||
const response = await fetch(file.preview)
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.href = blobUrl
|
||||
link.download = file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error)
|
||||
// Fallback - открыть в новой вкладке
|
||||
window.open(file.preview, '_blank')
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,6 +1298,21 @@ onUpdated(refreshIcons)
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
:class="{ active: activeDepartment === dept.id }"
|
||||
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
|
||||
>
|
||||
{{ dept.name }}
|
||||
{{ dept.name_departments }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -146,14 +146,14 @@ const closePanel = () => {
|
||||
}
|
||||
|
||||
// Сохранить задачу через Board компонент
|
||||
const handleSaveTask = (taskData) => {
|
||||
boardRef.value?.saveTask(taskData, editingColumnId.value)
|
||||
const handleSaveTask = async (taskData) => {
|
||||
await boardRef.value?.saveTask(taskData, editingColumnId.value)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Удалить задачу через Board компонент
|
||||
const handleDeleteTask = (cardId) => {
|
||||
boardRef.value?.deleteTask(cardId, editingColumnId.value)
|
||||
const handleDeleteTask = async (cardId) => {
|
||||
await boardRef.value?.deleteTask(cardId, editingColumnId.value)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user