1
0

Добавление в Архив + Фронт

1. Переписал модуль выпадающего слева меню
2. Добавил механику Архивации задач
3. Запоминания выбранного отдела
This commit is contained in:
2026-01-13 07:04:10 +07:00
parent 6688b8e37c
commit 44b6e636d4
17 changed files with 2434 additions and 1594 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ backend/public/*
# Личные файлы
deploy.js
deploy.php
.vscode

View File

@@ -95,6 +95,14 @@ if ($method === 'POST') {
RestApi::response($result);
}
// Установка статуса архивации задачи
if ($action === 'set_archive') {
$id = $data['id'] ?? null;
$archive = $data['archive'] ?? 1;
$result = Task::setArchive($id, $archive);
RestApi::response($result);
}
// Метод не указан
if (!$action) {
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
@@ -103,8 +111,16 @@ if ($method === 'POST') {
if ($method === 'GET') {
// Получение всех задач
// ?archive=0 (неархивные, по умолчанию), ?archive=1 (архивные), ?archive=all (все)
$archive = $_GET['archive'] ?? 0;
if ($archive === 'all') {
$archive = null;
} else {
$archive = (int)$archive;
}
$task = new Task();
$tasks = $task->getAll();
$tasks = $task->getAll($archive);
RestApi::response(['success' => true, 'data' => $tasks]);
}

View File

@@ -15,6 +15,7 @@ class Task extends BaseEntity {
public $title;
public $descript;
public $descript_full;
public $archive;
// Валидация данных
protected function validate() {
@@ -65,6 +66,7 @@ class Task extends BaseEntity {
'title' => $this->title,
'descript' => $this->descript ?: null,
'descript_full' => $this->descript_full ?: null,
'archive' => 0,
'date_create' => date('Y-m-d H:i:s'),
'file_img' => '[]'
]);
@@ -175,7 +177,13 @@ class Task extends BaseEntity {
}
// Получение всех задач
public function getAll() {
// $archive: 0 = неархивные, 1 = архивные, null = все
public function getAll($archive = 0) {
$where = [];
if ($archive !== null) {
$where['archive'] = $archive ? 1 : 0;
}
$tasks = Database::select($this->db_name, [
'id',
'id_department',
@@ -188,8 +196,9 @@ class Task extends BaseEntity {
'file_img',
'title',
'descript',
'descript_full'
]);
'descript_full',
'archive'
], $where);
// Декодируем JSON и получаем avatar_url из accounts
return array_map(function($task) {
@@ -243,6 +252,29 @@ 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) {
RestApi::response([
'success' => false,
'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"']
], 400);
}
// Обновляем archive
Database::update('cards_task', [
'archive' => $archive ? 1 : 0
], [
'id' => $id
]);
return ['success' => true, 'archive' => $archive ? 1 : 0];
}
}
?>

View File

@@ -70,7 +70,8 @@ export const columnsApi = {
// ==================== CARDS ====================
export const cardsApi = {
getAll: () => request('/api/task', { credentials: 'include' }),
// archive: 0 = неархивные (по умолчанию), 1 = архивные, 'all' = все
getAll: (archive = 0) => request(`/api/task${archive !== 0 ? `?archive=${archive}` : ''}`, { credentials: 'include' }),
updateOrder: (id, column_id, to_index) => request('/api/task', {
method: 'POST',
credentials: 'include',
@@ -94,6 +95,12 @@ export const cardsApi = {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id })
}),
setArchive: (id, archive = 1) => request('/api/task', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_archive', id, archive })
})
}

View File

@@ -10,6 +10,7 @@
@drop-card="handleDropCard"
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
@create-task="emit('create-task', column.id)"
@archive-task="archiveTask"
/>
</div>
</div>
@@ -238,7 +239,20 @@ const deleteTask = async (cardId, columnId) => {
}
}
defineExpose({ saveTask, deleteTask })
const archiveTask = async (cardId) => {
// Архивируем на сервере
const result = await cardsApi.setArchive(cardId, 1)
if (result.success) {
// Удаляем из локального списка (задача уходит в архив)
const index = localCards.value.findIndex(c => c.id === cardId)
if (index !== -1) {
localCards.value.splice(index, 1)
}
}
}
defineExpose({ saveTask, deleteTask, archiveTask })
</script>
<style scoped>

View File

@@ -19,6 +19,14 @@
</span>
</div>
<div class="header-right">
<button
v-if="canArchive"
class="btn-archive-card"
@click.stop="handleArchive"
title="В архив"
>
<i data-lucide="archive"></i>
</button>
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
<i data-lucide="image-plus"></i>
</span>
@@ -69,7 +77,7 @@ const props = defineProps({
}
})
// defineEmits(['delete']) - будет при раскрытии карточки
const emit = defineEmits(['archive'])
const refreshIcons = () => {
if (window.lucide) {
@@ -146,6 +154,15 @@ const daysLeftText = computed(() => {
const isAvatarUrl = (value) => {
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
}
// Можно ли архивировать (только если колонка 4)
const canArchive = computed(() => {
return Number(props.columnId) === 4
})
const handleArchive = () => {
emit('archive', props.card.id)
}
</script>
<style scoped>
@@ -279,4 +296,33 @@ const isAvatarUrl = (value) => {
.due-date.overdue {
color: var(--red);
}
.btn-archive-card {
display: none;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
}
.btn-archive-card i,
.btn-archive-card svg {
width: 18px;
height: 18px;
}
.card:hover .btn-archive-card {
display: flex;
}
.btn-archive-card:hover {
background: var(--orange);
color: #000;
}
</style>

View File

@@ -30,6 +30,7 @@
:departments="departments"
:labels="labels"
@click="emit('open-task', card)"
@archive="emit('archive-task', $event)"
/>
</template>
@@ -56,7 +57,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['drop-card', 'open-task', 'create-task'])
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task'])
const refreshIcons = () => {
if (window.lucide) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,454 @@
<template>
<div class="file-uploader">
<input
type="file"
ref="fileInputRef"
:accept="acceptString"
:multiple="multiple"
@change="handleFileSelect"
style="display: none"
>
<!-- Пустая зона drag & drop -->
<div
v-if="files.length === 0"
class="file-dropzone"
:class="{ 'dragover': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
@click="triggerFileInput"
>
<i data-lucide="image-plus" class="dropzone-icon"></i>
<span class="dropzone-text">{{ dropzoneText }}</span>
<span class="dropzone-subtext">{{ dropzoneSubtext }}</span>
</div>
<!-- Сетка с файлами -->
<div
v-else
class="files-container"
:class="{ 'dragover': isDragging }"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleFileDrop"
>
<!-- Превью файлов -->
<div
v-for="(file, index) in visibleFiles"
:key="file.name + '-' + file.size"
class="file-preview-item"
>
<div class="file-actions">
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
<i data-lucide="download"></i>
</button>
<button class="btn-remove-file" @click.stop="$emit('remove', index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
<div class="file-thumbnail" @click="!isArchive(file) && $emit('preview', file)">
<template v-if="isArchive(file)">
<div class="archive-icon">
<i data-lucide="archive"></i>
<span class="archive-ext">.{{ getFileExt(file) }}</span>
</div>
</template>
<img v-else :src="getPreviewUrl(file)" :alt="file.name">
</div>
</div>
<!-- Кнопка добавить ещё -->
<div class="file-add-btn" @click="triggerFileInput">
<i data-lucide="plus"></i>
<span>Добавить</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue'
const props = defineProps({
files: {
type: Array,
default: () => []
// Формат: [{ name, size, type, preview, isNew, toDelete }]
},
allowedExtensions: {
type: Array,
default: () => ['png', 'jpg', 'jpeg', 'zip', 'rar']
},
archiveExtensions: {
type: Array,
default: () => ['zip', 'rar']
},
maxFileSize: {
type: Number,
default: 10 * 1024 * 1024 // 10 MB
},
multiple: {
type: Boolean,
default: true
},
dropzoneText: {
type: String,
default: 'Перетащите файлы сюда'
},
dropzoneSubtext: {
type: String,
default: 'или нажмите для выбора'
},
getFullUrl: {
type: Function,
default: (url) => url
}
})
const emit = defineEmits(['add', 'remove', 'preview', 'error'])
const fileInputRef = ref(null)
const isDragging = ref(false)
// Видимые файлы (без помеченных на удаление)
const visibleFiles = computed(() => props.files.filter(f => !f.toDelete))
// Accept строка для input
const acceptString = computed(() => {
const mimeTypes = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'zip': 'application/zip',
'rar': 'application/x-rar-compressed'
}
const exts = props.allowedExtensions.map(e => `.${e}`)
const mimes = props.allowedExtensions.map(e => mimeTypes[e]).filter(Boolean)
return [...exts, ...mimes].join(',')
})
// Получить расширение файла
const getFileExt = (file) => {
return file.name?.split('.').pop()?.toLowerCase() || ''
}
// Проверка разрешён ли файл
const isAllowedFile = (file) => {
return props.allowedExtensions.includes(getFileExt(file))
}
// Проверка является ли файл архивом
const isArchive = (file) => {
return props.archiveExtensions.includes(getFileExt(file))
}
// Получить URL превью
const getPreviewUrl = (file) => {
return props.getFullUrl(file.preview)
}
// Открыть диалог выбора файлов
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// Обработка выбора файлов
const handleFileSelect = (event) => {
const files = event.target.files
if (files) {
processFiles(Array.from(files))
}
event.target.value = ''
}
// Обработка drop
const handleFileDrop = (event) => {
isDragging.value = false
const files = event.dataTransfer.files
if (files) {
processFiles(Array.from(files))
}
}
// Обработка файлов
const processFiles = (fileList) => {
for (const file of fileList) {
// Проверка типа
if (!isAllowedFile(file)) {
emit('error', `Файл "${file.name}" не поддерживается.`)
continue
}
// Проверка размера
if (file.size > props.maxFileSize) {
emit('error', `Файл "${file.name}" слишком большой.`)
continue
}
// Проверяем дубликат
const isDuplicate = props.files.some(
f => f.name === file.name && f.size === file.size
)
if (isDuplicate) continue
// Создаём превью
const reader = new FileReader()
reader.onload = (e) => {
emit('add', {
file: file,
name: file.name,
size: file.size,
type: file.type,
preview: e.target.result,
isNew: true
})
}
reader.readAsDataURL(file)
}
}
// Скачивание файла
const downloadFile = async (file) => {
const url = getPreviewUrl(file)
try {
if (file.preview?.startsWith('data:')) {
const link = document.createElement('a')
link.href = file.preview
link.download = file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
const response = await fetch(url)
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
}
} catch (error) {
console.error('Ошибка скачивания:', error)
window.open(url, '_blank')
}
}
// Обновление иконок
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Экспортируем методы
defineExpose({
triggerFileInput
})
</script>
<style scoped>
.file-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 24px;
background: rgba(255, 255, 255, 0.02);
border: 2px dashed rgba(255, 255, 255, 0.12);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.file-dropzone:hover {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.04);
}
.file-dropzone.dragover {
border-color: var(--accent);
background: rgba(0, 212, 170, 0.08);
}
.dropzone-icon {
width: 36px;
height: 36px;
color: var(--text-muted);
opacity: 0.6;
transition: all 0.2s;
}
.file-dropzone:hover .dropzone-icon,
.file-dropzone.dragover .dropzone-icon {
color: var(--accent);
opacity: 1;
}
.dropzone-text {
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.dropzone-subtext {
font-size: 12px;
color: var(--text-muted);
}
.files-container {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
border: 2px dashed rgba(255, 255, 255, 0.12);
border-radius: 12px;
transition: all 0.2s ease;
}
.files-container.dragover {
border-color: var(--accent);
background: rgba(0, 212, 170, 0.08);
}
.file-preview-item {
position: relative;
min-width: 0;
}
.file-thumbnail {
width: 100%;
padding-bottom: 100%;
position: relative;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: opacity 0.15s;
overflow: hidden;
}
.file-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.archive-icon {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
}
.archive-icon i {
width: 32px;
height: 32px;
opacity: 0.7;
}
.archive-ext {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
margin-top: 4px;
opacity: 0.6;
}
.file-preview-item:hover .file-thumbnail {
opacity: 0.85;
}
.file-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 4px;
z-index: 10;
opacity: 0;
transition: opacity 0.15s;
}
.file-preview-item:hover .file-actions {
opacity: 1;
}
.btn-remove-file,
.btn-download-file {
width: 26px;
height: 26px;
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.btn-remove-file i,
.btn-download-file i {
width: 14px;
height: 14px;
}
.btn-remove-file:hover {
background: #ef4444;
}
.btn-download-file:hover {
background: var(--accent);
color: #000;
}
.file-add-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
aspect-ratio: 1;
background: rgba(255, 255, 255, 0.04);
border: 2px dashed rgba(255, 255, 255, 0.15);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
color: var(--text-muted);
}
.file-add-btn:hover {
border-color: var(--accent);
background: rgba(0, 212, 170, 0.08);
color: var(--accent);
}
.file-add-btn i {
width: 24px;
height: 24px;
}
.file-add-btn span {
font-size: 11px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="field">
<div v-if="label" class="field-header">
<label>{{ label }}</label>
<slot name="actions"></slot>
</div>
<div v-if="hint" class="field-hint">{{ hint }}</div>
<slot></slot>
<div v-if="error" class="field-error">
<i data-lucide="alert-circle"></i>
{{ error }}
</div>
</div>
</template>
<script setup>
import { onMounted, onUpdated } from 'vue'
defineProps({
label: {
type: String,
default: ''
},
hint: {
type: String,
default: ''
},
error: {
type: String,
default: ''
}
})
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 10px;
}
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field-hint {
font-size: 11px;
color: var(--text-muted);
margin-top: -6px;
}
.field-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
color: #ef4444;
font-size: 12px;
}
.field-error i {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<Transition name="dialog">
<div v-if="file" class="image-preview-overlay" @click="$emit('close')">
<div class="image-preview-container" @click.stop>
<div class="preview-actions">
<button class="btn-download-preview" @click="downloadFile" title="Скачать">
<i data-lucide="download"></i>
</button>
<button v-if="showDelete" class="btn-delete-preview" @click="$emit('delete')" title="Удалить">
<i data-lucide="trash-2"></i>
</button>
<button class="btn-close-preview" @click="$emit('close')" title="Закрыть">
<i data-lucide="x"></i>
</button>
</div>
<!-- Архив -->
<template v-if="isArchive">
<div class="archive-preview-icon">
<i data-lucide="archive"></i>
<span>{{ file.name }}</span>
</div>
</template>
<!-- Изображение -->
<img v-else :src="previewUrl" :alt="file.name" class="image-preview-full">
<div class="image-preview-info">
<span class="preview-name">{{ file.name }}</span>
<span class="preview-size">{{ formatFileSize(file.size) }}</span>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed, onMounted, onUpdated } from 'vue'
const props = defineProps({
file: {
type: Object,
default: null
// Формат: { name, size, type, preview }
},
showDelete: {
type: Boolean,
default: true
},
archiveExtensions: {
type: Array,
default: () => ['zip', 'rar']
},
getFullUrl: {
type: Function,
default: (url) => url
}
})
const emit = defineEmits(['close', 'delete'])
// URL превью
const previewUrl = computed(() => {
if (!props.file) return ''
return props.getFullUrl(props.file.preview)
})
// Проверка архива
const isArchive = computed(() => {
if (!props.file) return false
const ext = props.file.name?.split('.').pop()?.toLowerCase() || ''
return props.archiveExtensions.includes(ext)
})
// Форматирование размера
const formatFileSize = (bytes) => {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' Б'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ'
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ'
}
// Скачивание
const downloadFile = async () => {
if (!props.file) return
const url = previewUrl.value
try {
if (props.file.preview?.startsWith('data:')) {
const link = document.createElement('a')
link.href = props.file.preview
link.download = props.file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
const response = await fetch(url)
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = props.file.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
}
} catch (error) {
console.error('Ошибка скачивания:', error)
window.open(url, '_blank')
}
}
// Обновление иконок
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
padding: 40px;
}
.image-preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.preview-actions {
position: absolute;
top: -50px;
right: 0;
display: flex;
gap: 8px;
}
.btn-close-preview,
.btn-download-preview,
.btn-delete-preview {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-close-preview:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.1);
}
.btn-download-preview:hover {
background: var(--accent);
color: #000;
transform: scale(1.1);
}
.btn-delete-preview:hover {
background: #ef4444;
transform: scale(1.1);
}
.btn-close-preview i,
.btn-download-preview i,
.btn-delete-preview i {
width: 20px;
height: 20px;
}
.image-preview-full {
max-width: 100%;
max-height: calc(90vh - 60px);
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.archive-preview-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 60px;
background: var(--bg-card);
border-radius: 16px;
color: var(--text-secondary);
}
.archive-preview-icon i {
width: 64px;
height: 64px;
opacity: 0.6;
}
.archive-preview-icon span {
font-size: 14px;
font-weight: 500;
}
.image-preview-info {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
.preview-name {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.preview-size {
font-size: 12px;
color: var(--text-muted);
}
/* Transition */
.dialog-enter-active,
.dialog-leave-active {
transition: opacity 0.2s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div class="rich-editor-wrapper">
<!-- Кнопки форматирования -->
<div v-if="showToolbar" class="format-buttons">
<button
type="button"
class="format-btn"
@mousedown.prevent="applyFormat('bold')"
title="Жирный (Ctrl+B)"
>
<i data-lucide="bold"></i>
</button>
<button
type="button"
class="format-btn"
@mousedown.prevent="applyFormat('italic')"
title="Курсив (Ctrl+I)"
>
<i data-lucide="italic"></i>
</button>
<button
type="button"
class="format-btn"
@mousedown.prevent="applyFormat('underline')"
title="Подчёркивание (Ctrl+U)"
>
<i data-lucide="underline"></i>
</button>
</div>
<!-- Редактируемое поле -->
<div
class="rich-editor"
:class="{ 'is-empty': !modelValue }"
contenteditable="true"
ref="editorRef"
@input="onInput"
@paste="onPaste"
@keydown="onKeydown"
@click="onClick"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
:data-placeholder="placeholder"
></div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUpdated } from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: 'Введите текст...'
},
showToolbar: {
type: Boolean,
default: true
},
autoLinkify: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
const editorRef = ref(null)
// Применить форматирование
const applyFormat = (command) => {
editorRef.value?.focus()
document.execCommand(command, false, null)
syncContent()
}
// Синхронизация содержимого
const syncContent = () => {
if (!editorRef.value) return
let html = editorRef.value.innerHTML
// Нормализуем переносы строк
html = html.replace(/<\/div><div>/gi, '<br>')
html = html.replace(/<div>/gi, '')
html = html.replace(/<\/div>/gi, '')
emit('update:modelValue', html)
}
// Обработка ввода
const onInput = () => {
syncContent()
}
// Обработка клика (открытие ссылок)
const onClick = (e) => {
if (e.target.tagName === 'A') {
e.preventDefault()
window.open(e.target.href, '_blank')
}
}
// Обработка вставки
const onPaste = (e) => {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
if (props.autoLinkify) {
setTimeout(linkifyContent, 0)
}
}
// Горячие клавиши
const onKeydown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'b') {
e.preventDefault()
applyFormat('bold')
} else if (e.key === 'i') {
e.preventDefault()
applyFormat('italic')
} else if (e.key === 'u') {
e.preventDefault()
applyFormat('underline')
}
}
// После пробела или Enter — подсвечиваем ссылки
if (props.autoLinkify && (e.key === ' ' || e.key === 'Enter')) {
setTimeout(linkifyContent, 0)
}
}
// Автоматическое преобразование URL в ссылки
const linkifyContent = () => {
if (!editorRef.value) return
const html = editorRef.value.innerHTML
const parts = html.split(/(<a [^>]*>.*?<\/a>)/gi)
let changed = false
const newParts = parts.map(part => {
if (part.startsWith('<a ')) return part
const newPart = part.replace(
/(https?:\/\/[^\s<>"]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>'
)
if (newPart !== part) changed = true
return newPart
})
if (changed) {
editorRef.value.innerHTML = newParts.join('')
// Ставим курсор в конец
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(editorRef.value)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
syncContent()
}
}
// Преобразование URL в ссылки
const linkifyHtml = (html) => {
if (!html) return ''
if (html.includes('<a ')) return html
return html.replace(
/(https?:\/\/[^\s<>"]+)/g,
'<a href="$1" target="_blank" rel="noopener">$1</a>'
)
}
// Установка содержимого
const setContent = (html) => {
if (editorRef.value) {
editorRef.value.innerHTML = linkifyHtml(html || '')
}
}
// Следим за внешними изменениями modelValue
watch(() => props.modelValue, (newVal, oldVal) => {
// Обновляем только если изменение пришло извне
if (editorRef.value && editorRef.value.innerHTML !== newVal) {
setContent(newVal)
}
}, { immediate: true })
// Обновление иконок Lucide
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(() => {
refreshIcons()
setContent(props.modelValue)
})
onUpdated(refreshIcons)
// Экспортируем методы для использования извне
defineExpose({
setContent,
applyFormat,
focus: () => editorRef.value?.focus()
})
</script>
<style scoped>
.rich-editor-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
.format-buttons {
display: flex;
gap: 3px;
}
.format-btn {
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 10px;
height: 10px;
}
.rich-editor {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 14px 16px;
min-height: 120px;
max-height: 400px;
overflow-y: auto;
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
transition: border-color 0.15s, background 0.15s;
word-break: break-word;
outline: none;
resize: vertical;
}
.rich-editor:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.rich-editor:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.rich-editor::-webkit-scrollbar {
width: 6px;
}
.rich-editor::-webkit-scrollbar-track {
background: transparent;
}
.rich-editor::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.rich-editor :deep(a) {
color: var(--accent);
text-decoration: none;
cursor: pointer;
}
.rich-editor :deep(a:hover) {
text-decoration: underline;
}
.rich-editor :deep(b),
.rich-editor :deep(strong) {
font-weight: 600;
}
.rich-editor :deep(i),
.rich-editor :deep(em) {
font-style: italic;
}
.rich-editor :deep(u) {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div class="select-dropdown" ref="dropdownRef">
<!-- Триггер -->
<button class="dropdown-trigger" @click="toggleDropdown" :disabled="disabled">
<slot name="selected" :selected="selectedOption">
<img
v-if="selectedOption?.avatar"
:src="selectedOption.avatar"
:alt="selectedOption.label"
class="option-avatar"
>
<span v-else-if="!selectedOption" class="no-selection-icon"></span>
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
</slot>
<i data-lucide="chevron-down" class="dropdown-arrow"></i>
</button>
<!-- Выпадающий список -->
<Transition name="dropdown">
<div v-if="isOpen" class="dropdown-menu">
<input
v-if="searchable"
type="text"
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="dropdown-search"
ref="searchInputRef"
@click.stop
>
<div class="dropdown-list">
<!-- Опция "не выбрано" -->
<button
v-if="allowEmpty"
class="dropdown-item"
:class="{ active: !modelValue }"
@click="selectOption(null)"
>
<span class="no-selection-icon"></span>
<span>{{ emptyLabel }}</span>
</button>
<!-- Опции -->
<button
v-for="option in filteredOptions"
:key="option.value"
class="dropdown-item"
:class="{ active: modelValue === option.value }"
@click="selectOption(option.value)"
>
<slot name="option" :option="option">
<img
v-if="option.avatar"
:src="option.avatar"
:alt="option.label"
class="option-avatar"
>
<div class="option-content">
<span class="option-label">{{ option.label }}</span>
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
</div>
</slot>
</button>
<!-- Пустой результат поиска -->
<div v-if="searchable && filteredOptions.length === 0" class="dropdown-empty">
Ничего не найдено
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, onUpdated, nextTick } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number, null],
default: null
},
options: {
type: Array,
default: () => []
// Формат: [{ value: 1, label: 'Name', subtitle: '@telegram', avatar: 'url' }]
},
placeholder: {
type: String,
default: 'Выберите...'
},
searchable: {
type: Boolean,
default: false
},
searchPlaceholder: {
type: String,
default: 'Поиск...'
},
allowEmpty: {
type: Boolean,
default: true
},
emptyLabel: {
type: String,
default: 'Не выбрано'
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const dropdownRef = ref(null)
const searchInputRef = ref(null)
const isOpen = ref(false)
const searchQuery = ref('')
// Выбранная опция
const selectedOption = computed(() => {
if (!props.modelValue) return null
return props.options.find(opt => opt.value === props.modelValue)
})
// Отфильтрованные опции
const filteredOptions = computed(() => {
if (!searchQuery.value.trim()) return props.options
const query = searchQuery.value.toLowerCase()
return props.options.filter(opt =>
opt.label?.toLowerCase().includes(query) ||
opt.subtitle?.toLowerCase().includes(query)
)
})
// Переключение dropdown
const toggleDropdown = async () => {
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
searchInputRef.value?.focus()
refreshIcons()
}
}
// Выбор опции
const selectOption = (value) => {
emit('update:modelValue', value)
emit('change', value)
isOpen.value = false
searchQuery.value = ''
}
// Закрытие при клике вне
const handleClickOutside = (e) => {
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
isOpen.value = false
searchQuery.value = ''
}
}
// Обновление иконок
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
refreshIcons()
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
onUpdated(refreshIcons)
</script>
<style scoped>
.select-dropdown {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 14px 16px;
height: 48px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-sizing: border-box;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
text-align: left;
}
.dropdown-trigger:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
}
.dropdown-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown-arrow {
width: 16px;
height: 16px;
margin-left: auto;
color: var(--text-muted);
flex-shrink: 0;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: #1f1f23;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
}
.dropdown-search {
width: 100%;
padding: 12px 14px;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.dropdown-search::placeholder {
color: var(--text-muted);
}
.dropdown-list {
max-height: 240px;
overflow-y: auto;
padding: 6px;
}
.dropdown-list::-webkit-scrollbar {
width: 6px;
}
.dropdown-list::-webkit-scrollbar-track {
background: transparent;
}
.dropdown-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: all 0.1s;
text-align: left;
}
.dropdown-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.dropdown-item.active {
background: var(--accent);
color: #000;
}
.option-avatar {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.option-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.option-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.option-subtitle {
font-size: 11px;
color: var(--text-muted);
}
.dropdown-item.active .option-subtitle {
color: rgba(0, 0, 0, 0.5);
}
.no-selection-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 14px;
flex-shrink: 0;
}
.dropdown-item.active .no-selection-icon {
background: rgba(0, 0, 0, 0.15);
}
.dropdown-empty {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* Transition */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<Transition name="panel">
<div
v-if="show"
class="panel-overlay"
@mousedown.self="onOverlayMouseDown"
@mouseup.self="onOverlayMouseUp"
>
<div
class="panel"
:style="{ width: panelWidth + 'px' }"
ref="panelRef"
@mousedown="overlayMouseDownTarget = false"
>
<!-- Ручка для изменения ширины -->
<div
class="resize-handle"
@mousedown="startResize"
></div>
<div class="panel-header">
<div class="header-content">
<slot name="header"></slot>
</div>
<button class="btn-close" @click="handleClose">
<i data-lucide="x"></i>
</button>
</div>
<div class="panel-body">
<slot></slot>
</div>
<div v-if="$slots.footer" class="panel-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
width: {
type: Number,
default: 600
},
minWidth: {
type: Number,
default: 500
},
maxWidth: {
type: Number,
default: 1000
},
resizable: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['close', 'update:show'])
const panelRef = ref(null)
const panelWidth = ref(props.width)
const isResizing = ref(false)
const overlayMouseDownTarget = ref(false)
// Resize логика
const startResize = (e) => {
if (!props.resizable) return
isResizing.value = true
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
const onResize = (e) => {
if (!isResizing.value) return
const newWidth = window.innerWidth - e.clientX
panelWidth.value = Math.min(props.maxWidth, Math.max(props.minWidth, newWidth))
}
const stopResize = () => {
setTimeout(() => {
isResizing.value = false
}, 100)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// Закрытие по клику на overlay
const onOverlayMouseDown = () => {
overlayMouseDownTarget.value = true
}
const onOverlayMouseUp = () => {
if (overlayMouseDownTarget.value && !isResizing.value) {
handleClose()
}
overlayMouseDownTarget.value = false
}
const handleClose = () => {
emit('close')
emit('update:show', false)
}
// Обновление иконок Lucide
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Сброс ширины при открытии
watch(() => props.show, (newVal) => {
if (newVal) {
panelWidth.value = props.width
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
onUnmounted(() => {
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<style scoped>
.panel-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.panel {
position: fixed;
top: 0;
right: 0;
max-width: 100%;
height: 100vh;
background: #18181b;
display: flex;
flex-direction: column;
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.4);
}
.resize-handle {
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
background: transparent;
transition: background 0.15s;
z-index: 10;
}
.resize-handle:hover,
.resize-handle:active {
background: var(--accent);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.header-content {
display: flex;
align-items: baseline;
gap: 16px;
flex: 1;
}
.header-content :deep(h2) {
font-size: 20px;
font-weight: 500;
margin: 0;
}
.header-content :deep(.header-date) {
font-size: 13px;
color: var(--text-muted);
}
.btn-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.15s;
flex-shrink: 0;
}
.btn-close i {
width: 20px;
height: 20px;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.panel-body {
flex: 1;
padding: 32px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.panel-body::-webkit-scrollbar {
width: 6px;
}
.panel-body::-webkit-scrollbar-track {
background: transparent;
}
.panel-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.panel-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 32px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* Transition */
.panel-enter-active,
.panel-leave-active {
transition: all 0.3s ease;
}
.panel-enter-active .panel,
.panel-leave-active .panel {
transition: transform 0.3s ease;
}
.panel-enter-from,
.panel-leave-to {
opacity: 0;
}
.panel-enter-from .panel,
.panel-leave-to .panel {
transform: translateX(100%);
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<div class="tags-select">
<button
v-for="option in options"
:key="option.value"
class="tag-option"
:class="{ active: isSelected(option.value) }"
:style="{ '--tag-color': option.color || defaultColor }"
@click="toggleOption(option.value)"
>
<span v-if="option.icon" class="tag-icon">{{ option.icon }}</span>
{{ option.label }}
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number, Array, null],
default: null
},
options: {
type: Array,
default: () => []
// Формат: [{ value: 1, label: 'Name', color: '#00d4aa', icon: '🔥' }]
},
multiple: {
type: Boolean,
default: false
},
allowDeselect: {
type: Boolean,
default: true
},
defaultColor: {
type: String,
default: 'var(--accent)'
}
})
const emit = defineEmits(['update:modelValue', 'change'])
// Проверка выбрана ли опция
const isSelected = (value) => {
if (props.multiple) {
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
}
return props.modelValue === value
}
// Переключение опции
const toggleOption = (value) => {
let newValue
if (props.multiple) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = current.indexOf(value)
if (index > -1) {
if (props.allowDeselect) {
current.splice(index, 1)
}
} else {
current.push(value)
}
newValue = current
} else {
// Single select
if (props.modelValue === value && props.allowDeselect) {
newValue = null
} else {
newValue = value
}
}
emit('update:modelValue', newValue)
emit('change', newValue)
}
</script>
<style scoped>
.tags-select {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag-option {
padding: 8px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
transition: all 0.15s;
}
.tag-option:hover {
border-color: var(--tag-color);
color: var(--tag-color);
}
.tag-option.active {
background: var(--tag-color);
border-color: var(--tag-color);
color: #000;
}
.tag-icon {
font-size: 14px;
margin-right: 4px;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<input
type="text"
class="text-input"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
@input="$emit('update:modelValue', $event.target.value)"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
@keydown="$emit('keydown', $event)"
>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
readonly: {
type: Boolean,
default: false
}
})
defineEmits(['update:modelValue', 'focus', 'blur', 'keydown'])
</script>
<style scoped>
.text-input {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 14px 16px;
height: 48px;
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
outline: none;
transition: all 0.15s;
width: 100%;
box-sizing: border-box;
}
.text-input:focus {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.06);
}
.text-input::placeholder {
color: var(--text-muted);
}
.text-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -73,12 +73,13 @@
@close="closePanel"
@save="handleSaveTask"
@delete="handleDeleteTask"
@archive="handleArchiveTask"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted } from 'vue'
import Sidebar from '../components/Sidebar.vue'
import Header from '../components/Header.vue'
import Board from '../components/Board.vue'
@@ -86,7 +87,18 @@ import TaskPanel from '../components/TaskPanel.vue'
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
// Активный фильтр по отделу (null = все)
const activeDepartment = ref(null)
// Восстанавливаем из localStorage
const savedDepartment = localStorage.getItem('activeDepartment')
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
// Сохраняем в localStorage при изменении
watch(activeDepartment, (newVal) => {
if (newVal === null) {
localStorage.removeItem('activeDepartment')
} else {
localStorage.setItem('activeDepartment', newVal.toString())
}
})
// Статистика для шапки
const stats = ref({ total: 0, inProgress: 0, done: 0 })
// Данные из API
@@ -157,6 +169,12 @@ const handleDeleteTask = async (cardId) => {
closePanel()
}
// Архивировать задачу через Board компонент
const handleArchiveTask = async (cardId) => {
await boardRef.value?.archiveTask(cardId)
closePanel()
}
// Инициализация при монтировании
onMounted(() => {
fetchData()