Инициализация проекта
Загрузка проекта на GIT
This commit is contained in:
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Taskboard
|
||||
|
||||
Kanban-доска для управления задачами команды.
|
||||
|
||||
- 📋 Kanban-доска с колонками и карточками задач
|
||||
- 👥 Страница команды с профилями участников
|
||||
- 🏷️ Метки и департаменты для категоризации задач
|
||||
- 📎 Прикрепление изображений к задачам
|
||||
|
||||
## Технологии
|
||||
|
||||
- Vue 3
|
||||
- PHP
|
||||
- MySQL
|
||||
40
backend/app/class/database/class_Database.php
Normal file
40
backend/app/class/database/class_Database.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Medoo\Medoo;
|
||||
|
||||
class Database
|
||||
{
|
||||
public static $db;
|
||||
|
||||
// Магический метод для вызова методов класса Medoo
|
||||
public static function __callStatic($name, $arguments) {
|
||||
if (!self::$db) {
|
||||
self::init();
|
||||
}
|
||||
|
||||
return call_user_func_array([self::$db, $name], $arguments);
|
||||
}
|
||||
|
||||
// Инициализация подключения к БД
|
||||
public static function init() {
|
||||
if (!self::$db) {
|
||||
try {
|
||||
self::$db = new Medoo([
|
||||
'type' => 'mysql',
|
||||
'host' => DB_HOST,
|
||||
'database' => DB_NAME,
|
||||
'username' => DB_USER,
|
||||
'password' => DB_PASS,
|
||||
'port' => DB_PORT,
|
||||
'charset' => DB_CHARSET,
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'error' => \PDO::ERRMODE_EXCEPTION
|
||||
]);
|
||||
} catch (\PDOException $e) {
|
||||
die('Ошибка подключения к БД: ' . $e->getMessage());
|
||||
exit;
|
||||
}
|
||||
}
|
||||
return self::$db;
|
||||
}
|
||||
}
|
||||
2311
backend/app/class/database/class_Medoo.php
Normal file
2311
backend/app/class/database/class_Medoo.php
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/app/config.php
Normal file
19
backend/app/config.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// Подключение классов базы данных
|
||||
require_once __DIR__ . '/class/database/class_Medoo.php';
|
||||
require_once __DIR__ . '/class/database/class_Database.php';
|
||||
|
||||
// Подключение классов Функций
|
||||
require_once __DIR__ . '/functions/function.php';
|
||||
require_once __DIR__ . '/functions/routing.php';
|
||||
|
||||
// Данные подключения к БД
|
||||
define('DB_HOST', '192.168.1.9');
|
||||
define('DB_USER', 'root');
|
||||
define('DB_PASS', 'root');
|
||||
define('DB_NAME', 'taskboard');
|
||||
define('DB_PORT', 3306);
|
||||
define('DB_CHARSET', 'utf8mb4');
|
||||
|
||||
?>
|
||||
4
backend/app/functions/function.php
Normal file
4
backend/app/functions/function.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
|
||||
|
||||
?>
|
||||
19
backend/app/functions/routing.php
Normal file
19
backend/app/functions/routing.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
// Функция роутинга API
|
||||
function handleRouting($routes = []) {
|
||||
|
||||
$request = $_SERVER['REQUEST_URI'];
|
||||
$path = parse_url($request, PHP_URL_PATH);
|
||||
|
||||
if (isset($routes[$path])) {
|
||||
|
||||
$file_path = $routes[$path];
|
||||
global $_POST, $_FILES, $_SERVER, $_GET;
|
||||
include $file_path;
|
||||
exit;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
16
backend/index.php
Normal file
16
backend/index.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/app/config.php';
|
||||
|
||||
// Игнорируем запросы к favicon.ico и другим статическим файлам
|
||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
if (preg_match('/\.(ico|png|jpg|jpeg|gif|css|js|svg|woff|woff2|ttf|eot)$/i', $requestUri)) {
|
||||
http_response_code(404);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$accounts = Database::select('accounts', '*');
|
||||
|
||||
echo json_encode([$accounts]);
|
||||
5
front_vue/.gitignore
vendored
Normal file
5
front_vue/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
17
front_vue/index.html
Normal file
17
front_vue/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TaskBoard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Конфиг загружается до приложения (можно менять без перебилда) -->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1297
front_vue/package-lock.json
generated
Normal file
1297
front_vue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
front_vue/package.json
Normal file
18
front_vue/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "taskboard",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4"
|
||||
}
|
||||
}
|
||||
5
front_vue/public/config.js
Normal file
5
front_vue/public/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// Конфигурация приложения
|
||||
window.APP_CONFIG = {
|
||||
API_BASE: 'http://192.168.1.6'
|
||||
}
|
||||
|
||||
41
front_vue/src/App.vue
Normal file
41
front_vue/src/App.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Сброс стилей */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* CSS переменные (цветовая палитра) */
|
||||
:root {
|
||||
--bg-body: #111113;
|
||||
--bg-sidebar: #161618;
|
||||
--bg-main: #111113;
|
||||
--bg-card: rgba(255, 255, 255, 0.04);
|
||||
--bg-card-hover: rgba(255, 255, 255, 0.07);
|
||||
--bg-input: rgba(255, 255, 255, 0.04);
|
||||
--bg-secondary: #1a1a1f;
|
||||
--accent: #00d4aa;
|
||||
--accent-soft: rgba(0, 212, 170, 0.12);
|
||||
--blue: #60a5fa;
|
||||
--orange: #fbbf24;
|
||||
--pink: #f472b6;
|
||||
--red: #f87171;
|
||||
--green: #34d399;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1a6;
|
||||
--text-muted: #6b6b70;
|
||||
}
|
||||
|
||||
/* Базовые стили body */
|
||||
body {
|
||||
font-family: 'Outfit', -apple-system, sans-serif;
|
||||
background: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
61
front_vue/src/api.js
Normal file
61
front_vue/src/api.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Базовый URL API (берётся из внешнего config.js)
|
||||
const API_BASE = window.APP_CONFIG?.API_BASE || 'http://localhost'
|
||||
|
||||
// Базовая функция запроса
|
||||
const request = async (endpoint, options = {}) => {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, options)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ==================== AUTH ====================
|
||||
export const authApi = {
|
||||
login: (data) => request('/auth', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
check: () => request('/check-auth', { credentials: 'include' }),
|
||||
logout: () => request('/logout', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== DEPARTMENTS ====================
|
||||
export const departmentsApi = {
|
||||
getAll: () => request('/departments', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== LABELS ====================
|
||||
export const labelsApi = {
|
||||
getAll: () => request('/labels', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== COLUMNS ====================
|
||||
export const columnsApi = {
|
||||
getAll: () => request('/columns', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== CARDS ====================
|
||||
export const cardsApi = {
|
||||
getAll: () => request('/cards', { credentials: 'include' }),
|
||||
create: (data) => request('/cards', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
update: (id, data) => request(`/cards/${id}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
delete: (id) => request(`/cards/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== USERS ====================
|
||||
export const usersApi = {
|
||||
getAll: () => request('/users', { credentials: 'include' })
|
||||
}
|
||||
217
front_vue/src/components/Board.vue
Normal file
217
front_vue/src/components/Board.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="board">
|
||||
<div class="columns">
|
||||
<Column
|
||||
v-for="column in filteredColumns"
|
||||
:key="column.id"
|
||||
:column="column"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
@drop-card="handleDropCard"
|
||||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||||
@create-task="emit('create-task', column.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||||
import Column from './Column.vue'
|
||||
|
||||
const props = defineProps({
|
||||
activeDepartment: Number,
|
||||
departments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
cards: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Локальная копия карточек для optimistic UI
|
||||
const localCards = ref([])
|
||||
|
||||
// Синхронизируем с props при загрузке данных
|
||||
watch(() => props.cards, (newCards) => {
|
||||
if (newCards.length > 0 && localCards.value.length === 0) {
|
||||
// Первая загрузка - копируем данные и добавляем order если нет
|
||||
localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({
|
||||
...card,
|
||||
order: card.order ?? idx
|
||||
}))
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Собираем колонки с карточками (используем localCards, сортируем по order)
|
||||
const columnsWithCards = computed(() => {
|
||||
return props.columns.map(col => ({
|
||||
id: col.id,
|
||||
title: col.name,
|
||||
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,
|
||||
title: card.title,
|
||||
description: card.descript,
|
||||
details: card.descript_full,
|
||||
departmentId: card.id_department,
|
||||
labelId: card.id_label,
|
||||
assignee: card.avatar_img,
|
||||
dueDate: card.date,
|
||||
dateCreate: card.date_create,
|
||||
files: card.files || (card.file_img || []).map(f => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
preview: f.url
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})
|
||||
|
||||
// Фильтруем колонки по активному отделу
|
||||
const filteredColumns = computed(() => {
|
||||
if (!props.activeDepartment) return columnsWithCards.value
|
||||
|
||||
return columnsWithCards.value.map(col => ({
|
||||
...col,
|
||||
cards: col.cards.filter(card => card.departmentId === props.activeDepartment)
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredTotalTasks = computed(() => {
|
||||
return filteredColumns.value.reduce((sum, col) => sum + col.cards.length, 0)
|
||||
})
|
||||
|
||||
const inProgressTasks = computed(() => {
|
||||
const col = filteredColumns.value.find(c => c.id === 3) // В работе
|
||||
return col ? col.cards.length : 0
|
||||
})
|
||||
|
||||
const completedTasks = computed(() => {
|
||||
const col = filteredColumns.value.find(c => c.id === 5) // Готово
|
||||
return col ? col.cards.length : 0
|
||||
})
|
||||
|
||||
// Отправляем статистику в родителя
|
||||
watch([filteredTotalTasks, inProgressTasks, completedTasks], () => {
|
||||
emit('stats-updated', {
|
||||
total: filteredTotalTasks.value,
|
||||
inProgress: inProgressTasks.value,
|
||||
done: completedTasks.value
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
const handleDropCard = ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||||
const card = localCards.value.find(c => c.id_card === cardId)
|
||||
if (!card) return
|
||||
|
||||
// Меняем колонку
|
||||
card.column_id = toColumnId
|
||||
|
||||
// Получаем карточки целевой колонки (без перемещаемой)
|
||||
const columnCards = localCards.value
|
||||
.filter(c => c.column_id === toColumnId && c.id_card !== cardId)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
// Вставляем карточку в нужную позицию и пересчитываем order
|
||||
columnCards.splice(toIndex, 0, card)
|
||||
columnCards.forEach((c, idx) => {
|
||||
c.order = idx
|
||||
})
|
||||
|
||||
// TODO: отправить изменение на сервер
|
||||
}
|
||||
|
||||
// Генератор id для новых карточек
|
||||
let nextCardId = 100
|
||||
|
||||
// Методы для модалки
|
||||
const saveTask = (taskData, columnId) => {
|
||||
if (taskData.id) {
|
||||
// Редактирование существующей карточки
|
||||
const card = localCards.value.find(c => c.id_card === taskData.id)
|
||||
if (card) {
|
||||
card.title = taskData.title
|
||||
card.descript = taskData.description
|
||||
card.descript_full = taskData.details
|
||||
card.id_department = taskData.departmentId
|
||||
card.id_label = taskData.labelId
|
||||
card.date = taskData.dueDate
|
||||
card.avatar_img = taskData.assignee
|
||||
card.files = taskData.files || []
|
||||
}
|
||||
} else {
|
||||
// Создание новой карточки (в конец колонки)
|
||||
const columnCards = localCards.value.filter(c => c.column_id === columnId)
|
||||
const maxOrder = columnCards.length > 0
|
||||
? Math.max(...columnCards.map(c => c.order)) + 1
|
||||
: 0
|
||||
|
||||
localCards.value.push({
|
||||
id_card: nextCardId++,
|
||||
id_department: taskData.departmentId,
|
||||
id_label: taskData.labelId,
|
||||
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 || []
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: отправить на сервер
|
||||
}
|
||||
|
||||
const deleteTask = (cardId, columnId) => {
|
||||
const index = localCards.value.findIndex(c => c.id_card === cardId)
|
||||
if (index !== -1) {
|
||||
localCards.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// TODO: отправить на сервер
|
||||
}
|
||||
|
||||
defineExpose({ saveTask, deleteTask })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
</style>
|
||||
281
front_vue/src/components/Card.vue
Normal file
281
front_vue/src/components/Card.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
:class="{ dragging: isDragging, 'has-label-color': cardLabelColor }"
|
||||
:style="cardLabelColor ? { '--label-bg': cardLabelColor } : {}"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="tag-group">
|
||||
<span v-if="cardLabel" class="label-icon">{{ cardLabel.icon }}</span>
|
||||
<span
|
||||
v-if="cardDepartment"
|
||||
class="tag"
|
||||
:style="{ color: cardDepartment.color }"
|
||||
>
|
||||
{{ cardDepartment.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
<div v-if="card.assignee" class="assignee">
|
||||
<img
|
||||
v-if="isAvatarUrl(card.assignee)"
|
||||
:src="card.assignee"
|
||||
alt="avatar"
|
||||
class="assignee-img"
|
||||
/>
|
||||
<span v-else>{{ card.assignee }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{{ card.title }}</h3>
|
||||
|
||||
<p v-if="card.description" class="card-description">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div class="card-footer">
|
||||
<span v-if="card.dateCreate" class="date-create">
|
||||
Создано: {{ formatDateWithYear(card.dateCreate) }}
|
||||
</span>
|
||||
<span v-if="card.dueDate" class="due-date" :class="dueDateStatus">
|
||||
{{ daysLeftText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
card: Object,
|
||||
columnId: [String, Number],
|
||||
index: Number,
|
||||
departments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// defineEmits(['delete']) - будет при раскрытии карточки
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const handleDragStart = (e) => {
|
||||
isDragging.value = true
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('cardId', props.card.id.toString())
|
||||
e.dataTransfer.setData('columnId', props.columnId)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
// Получаем отдел по id
|
||||
const cardDepartment = computed(() => {
|
||||
if (!props.card.departmentId) return null
|
||||
return props.departments.find(d => d.id === props.card.departmentId) || null
|
||||
})
|
||||
|
||||
// Получаем лейбл по id
|
||||
const cardLabel = computed(() => {
|
||||
if (!props.card.labelId) return null
|
||||
return props.labels.find(l => l.id === props.card.labelId) || null
|
||||
})
|
||||
|
||||
// Цвет лейбла для фона карточки
|
||||
const cardLabelColor = computed(() => {
|
||||
return cardLabel.value?.color || null
|
||||
})
|
||||
|
||||
const formatDateWithYear = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
const day = date.getDate()
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
const getDaysLeft = () => {
|
||||
if (!props.card.dueDate) return null
|
||||
const due = new Date(props.card.dueDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
due.setHours(0, 0, 0, 0)
|
||||
return Math.round((due - today) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const dueDateStatus = computed(() => {
|
||||
const days = getDaysLeft()
|
||||
if (days === null) return ''
|
||||
if (days < 0) return 'overdue'
|
||||
if (days <= 2) return 'soon'
|
||||
return ''
|
||||
})
|
||||
|
||||
const daysLeftText = computed(() => {
|
||||
const days = getDaysLeft()
|
||||
if (days === null) return ''
|
||||
if (days < 0) return `Просрочено: ${Math.abs(days)} дн.`
|
||||
if (days === 0) return 'Сегодня'
|
||||
if (days === 1) return 'Завтра'
|
||||
return `Осталось: ${days} дн.`
|
||||
})
|
||||
|
||||
const isAvatarUrl = (value) => {
|
||||
return value && (value.startsWith('http://') || value.startsWith('https://'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.card.has-label-color {
|
||||
background: color-mix(in srgb, var(--label-bg) 15%, var(--bg-card));
|
||||
border-left: 3px solid var(--label-bg);
|
||||
}
|
||||
|
||||
.card.has-label-color:hover {
|
||||
background: color-mix(in srgb, var(--label-bg) 20%, var(--bg-card-hover));
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.4;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.assignee {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--blue);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.assignee-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.date-create {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.files-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.files-indicator i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.due-date.soon {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.due-date.overdue {
|
||||
color: var(--red);
|
||||
}
|
||||
</style>
|
||||
243
front_vue/src/components/Column.vue
Normal file
243
front_vue/src/components/Column.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div
|
||||
class="column"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
ref="columnRef"
|
||||
>
|
||||
<div class="column-header">
|
||||
<div class="column-title-row">
|
||||
<span class="column-dot" :style="{ background: column.color }"></span>
|
||||
<h2 class="column-title">{{ column.title }}</h2>
|
||||
<span class="column-count">{{ column.cards.length }}</span>
|
||||
</div>
|
||||
<button class="column-add" @click="emit('create-task')">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cards" ref="cardsRef">
|
||||
<template v-for="(card, index) in column.cards" :key="card.id">
|
||||
<!-- Индикатор перед карточкой -->
|
||||
<div v-if="isDragOver && dropIndex === index" class="drop-indicator"></div>
|
||||
<Card
|
||||
:card="card"
|
||||
:column-id="column.id"
|
||||
:index="index"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
@click="emit('open-task', card)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Индикатор в конце списка -->
|
||||
<div v-if="isDragOver && dropIndex === column.cards.length" class="drop-indicator"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Card from './Card.vue'
|
||||
|
||||
const props = defineProps({
|
||||
column: Object,
|
||||
departments: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['drop-card', 'open-task', 'create-task'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
const columnRef = ref(null)
|
||||
const cardsRef = ref(null)
|
||||
const isDragOver = ref(false)
|
||||
const dropIndex = ref(-1)
|
||||
let dragEnterCounter = 0
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
dragEnterCounter++
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const calculateDropIndex = (clientY) => {
|
||||
if (!cardsRef.value) return props.column.cards.length
|
||||
|
||||
const cardElements = cardsRef.value.querySelectorAll('.card')
|
||||
let index = props.column.cards.length
|
||||
|
||||
for (let i = 0; i < cardElements.length; i++) {
|
||||
const rect = cardElements[i].getBoundingClientRect()
|
||||
const cardMiddle = rect.top + rect.height / 2
|
||||
|
||||
if (clientY < cardMiddle) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
isDragOver.value = true
|
||||
dropIndex.value = calculateDropIndex(e.clientY)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
dragEnterCounter--
|
||||
if (dragEnterCounter === 0) {
|
||||
isDragOver.value = false
|
||||
dropIndex.value = -1
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
const cardId = parseInt(e.dataTransfer.getData('cardId'))
|
||||
const fromColumnId = e.dataTransfer.getData('columnId')
|
||||
const toIndex = calculateDropIndex(e.clientY)
|
||||
|
||||
dragEnterCounter = 0
|
||||
isDragOver.value = false
|
||||
dropIndex.value = -1
|
||||
|
||||
emit('drop-card', {
|
||||
cardId,
|
||||
fromColumnId,
|
||||
toColumnId: props.column.id,
|
||||
toIndex
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.column {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.column.drag-over .cards {
|
||||
background: var(--accent-soft);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.column-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.column-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.column-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.column-add {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.column-add i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.column-add:hover {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.cards::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.cards::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cards::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cards::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.drop-indicator {
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
margin: 4px 0;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
</style>
|
||||
202
front_vue/src/components/ConfirmDialog.vue
Normal file
202
front_vue/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<Transition name="dialog">
|
||||
<div v-if="show" class="dialog-overlay" @click.self="handleCancel">
|
||||
<div class="dialog">
|
||||
<h3>{{ title }}</h3>
|
||||
<p v-html="message"></p>
|
||||
<div class="dialog-buttons">
|
||||
<button class="btn-cancel" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="showDiscard"
|
||||
class="btn-discard"
|
||||
@click="handleDiscard"
|
||||
>
|
||||
{{ discardText }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-confirm"
|
||||
:class="variant"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Подтверждение'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Вы уверены?'
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: 'Подтвердить'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: 'Отмена'
|
||||
},
|
||||
discardText: {
|
||||
type: String,
|
||||
default: 'Не сохранять'
|
||||
},
|
||||
showDiscard: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// Варианты: 'default', 'danger', 'warning'
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel', 'discard'])
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const handleDiscard = () => {
|
||||
emit('discard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog p {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-discard {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-discard:hover {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-confirm:hover {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
.btn-confirm.danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-confirm.danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-confirm.warning {
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-confirm.warning:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-active .dialog,
|
||||
.dialog-leave-active .dialog {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-enter-from .dialog,
|
||||
.dialog-leave-to .dialog {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
423
front_vue/src/components/DatePicker.vue
Normal file
423
front_vue/src/components/DatePicker.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div class="datepicker" ref="datepickerRef">
|
||||
<div class="datepicker-trigger" @click="toggleCalendar">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span v-if="modelValue">{{ formatDisplayDate(modelValue) }}</span>
|
||||
<span v-else class="placeholder">Выберите дату</span>
|
||||
<button v-if="modelValue" class="clear-btn" @click.stop="clearDate">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="calendar">
|
||||
<div class="calendar-header">
|
||||
<button class="nav-btn" @click="prevMonth">
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="current-month">{{ monthNames[currentMonth] }} {{ currentYear }}</span>
|
||||
<button class="nav-btn" @click="nextMonth">
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-weekdays">
|
||||
<span v-for="day in weekdays" :key="day">{{ day }}</span>
|
||||
</div>
|
||||
|
||||
<div class="calendar-days">
|
||||
<button
|
||||
v-for="day in calendarDays"
|
||||
:key="day.key"
|
||||
class="day"
|
||||
:class="{
|
||||
'other-month': !day.currentMonth,
|
||||
'today': day.isToday,
|
||||
'selected': day.isSelected
|
||||
}"
|
||||
@click="selectDate(day)"
|
||||
>
|
||||
{{ day.day }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-footer">
|
||||
<button class="today-btn" @click="selectToday">Сегодня</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const datepickerRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const currentMonth = ref(new Date().getMonth())
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
|
||||
const weekdays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
const monthNames = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
]
|
||||
|
||||
const formatDisplayDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const day = date.getDate()
|
||||
const month = monthNames[date.getMonth()]
|
||||
const year = date.getFullYear()
|
||||
return `${day} ${month} ${year}`
|
||||
}
|
||||
|
||||
const calendarDays = computed(() => {
|
||||
const days = []
|
||||
const firstDay = new Date(currentYear.value, currentMonth.value, 1)
|
||||
const lastDay = new Date(currentYear.value, currentMonth.value + 1, 0)
|
||||
|
||||
// День недели первого числа (0 = воскресенье, переводим в понедельник = 0)
|
||||
let startDay = firstDay.getDay() - 1
|
||||
if (startDay < 0) startDay = 6
|
||||
|
||||
// Дни предыдущего месяца
|
||||
const prevMonthLastDay = new Date(currentYear.value, currentMonth.value, 0).getDate()
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const day = prevMonthLastDay - i
|
||||
days.push({
|
||||
key: `prev-${day}`,
|
||||
day,
|
||||
currentMonth: false,
|
||||
date: new Date(currentYear.value, currentMonth.value - 1, day),
|
||||
isToday: false,
|
||||
isSelected: false
|
||||
})
|
||||
}
|
||||
|
||||
// Дни текущего месяца
|
||||
const today = new Date()
|
||||
const selectedDate = props.modelValue ? new Date(props.modelValue) : null
|
||||
|
||||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||||
const date = new Date(currentYear.value, currentMonth.value, day)
|
||||
const isToday = date.toDateString() === today.toDateString()
|
||||
const isSelected = selectedDate && date.toDateString() === selectedDate.toDateString()
|
||||
|
||||
days.push({
|
||||
key: `curr-${day}`,
|
||||
day,
|
||||
currentMonth: true,
|
||||
date,
|
||||
isToday,
|
||||
isSelected
|
||||
})
|
||||
}
|
||||
|
||||
// Дни следующего месяца (заполняем до 42 ячеек = 6 недель)
|
||||
const remaining = 42 - days.length
|
||||
for (let day = 1; day <= remaining; day++) {
|
||||
days.push({
|
||||
key: `next-${day}`,
|
||||
day,
|
||||
currentMonth: false,
|
||||
date: new Date(currentYear.value, currentMonth.value + 1, day),
|
||||
isToday: false,
|
||||
isSelected: false
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
const toggleCalendar = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
// Устанавливаем текущий месяц на выбранную дату или сегодня
|
||||
if (props.modelValue) {
|
||||
const date = new Date(props.modelValue)
|
||||
currentMonth.value = date.getMonth()
|
||||
currentYear.value = date.getFullYear()
|
||||
} else {
|
||||
currentMonth.value = new Date().getMonth()
|
||||
currentYear.value = new Date().getFullYear()
|
||||
}
|
||||
nextTick(() => {
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const prevMonth = () => {
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value--
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
}
|
||||
|
||||
const nextMonth = () => {
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value++
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
}
|
||||
|
||||
const selectDate = (day) => {
|
||||
const date = day.date
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dayNum = String(date.getDate()).padStart(2, '0')
|
||||
emit('update:modelValue', `${year}-${month}-${dayNum}`)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const selectToday = () => {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
emit('update:modelValue', `${year}-${month}-${day}`)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const clearDate = () => {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (datepickerRef.value && !datepickerRef.value.contains(e.target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
watch(isOpen, () => {
|
||||
nextTick(() => {
|
||||
if (window.lucide) window.lucide.createIcons()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.datepicker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.datepicker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 0 14px;
|
||||
height: 48px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.datepicker-trigger:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.datepicker-trigger i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.datepicker-trigger .placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clear-btn i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1e1e24;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-weekdays span {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.day.other-month {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day.selected:hover {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
.calendar-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
121
front_vue/src/components/Header.vue
Normal file
121
front_vue/src/components/Header.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="title-row">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<!-- Слот для фильтров (на одной строке с заголовком) -->
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Слот для статистики и прочего -->
|
||||
<slot name="stats"></slot>
|
||||
<button class="logout-btn" @click="logout" title="Выйти">
|
||||
<i data-lucide="log-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../api'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const logout = async () => {
|
||||
await authApi.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24px 32px;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.logout-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
98
front_vue/src/components/Sidebar.vue
Normal file
98
front_vue/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<!-- Логотип -->
|
||||
<div class="sidebar-logo">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
</div>
|
||||
|
||||
<!-- Меню навигации -->
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: $route.path === '/' }">
|
||||
<i data-lucide="kanban"></i>
|
||||
</router-link>
|
||||
<router-link to="/team" class="nav-item" :class="{ active: $route.path === '/team' }">
|
||||
<i data-lucide="users"></i>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 72px;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-right: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--accent);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sidebar-logo i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(0, 212, 170, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
</style>
|
||||
1551
front_vue/src/components/TaskPanel.vue
Normal file
1551
front_vue/src/components/TaskPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
5
front_vue/src/main.js
Normal file
5
front_vue/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
57
front_vue/src/router.js
Normal file
57
front_vue/src/router.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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'
|
||||
|
||||
// Проверка авторизации
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const data = await authApi.check()
|
||||
return data.status === 'ok'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainApp,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/team',
|
||||
name: 'team',
|
||||
component: TeamPage,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginPage
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard — проверка авторизации
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const isAuth = await checkAuth()
|
||||
|
||||
if (to.meta.requiresAuth && !isAuth) {
|
||||
// Не авторизован — на логин
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && isAuth) {
|
||||
// Уже авторизован — на главную
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
187
front_vue/src/views/LoginPage.vue
Normal file
187
front_vue/src/views/LoginPage.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<!-- Логотип -->
|
||||
<div class="login-logo">
|
||||
<i data-lucide="layout-grid"></i>
|
||||
<span>TaskBoard</span>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- Поле логина -->
|
||||
<div class="field">
|
||||
<input
|
||||
type="text"
|
||||
v-model="login"
|
||||
placeholder="Логин"
|
||||
autocomplete="username"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Поле пароля -->
|
||||
<div class="field">
|
||||
<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
placeholder="Пароль"
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Ошибка -->
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Кнопка входа -->
|
||||
<button type="submit" class="login-btn" :disabled="loading">
|
||||
<span v-if="loading">Вход...</span>
|
||||
<span v-else>Войти</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const data = await authApi.login({
|
||||
login: login.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
if (data.status === 'ok') {
|
||||
router.push('/')
|
||||
} else {
|
||||
error.value = 'Неверный логин или пароль'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Ошибка подключения к серверу'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-body);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-sidebar);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-logo i {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.login-logo span {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: #00e6b8;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
284
front_vue/src/views/MainApp.vue
Normal file
284
front_vue/src/views/MainApp.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<!-- Боковая панель навигации -->
|
||||
<Sidebar />
|
||||
|
||||
<!-- Основной контент -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Шапка с заголовком, фильтрами и статистикой -->
|
||||
<Header title="Доска задач">
|
||||
<template #filters>
|
||||
<div class="filters">
|
||||
<button
|
||||
class="filter-tag"
|
||||
:class="{ active: activeDepartment === null }"
|
||||
@click="activeDepartment = null"
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
v-for="dept in departments"
|
||||
:key="dept.id"
|
||||
class="filter-tag"
|
||||
:class="{ active: activeDepartment === dept.id }"
|
||||
@click="activeDepartment = activeDepartment === dept.id ? null : dept.id"
|
||||
>
|
||||
{{ dept.name }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #stats>
|
||||
<div class="header-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ stats.total }}</span>
|
||||
<span class="stat-label">задач</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ stats.inProgress }}</span>
|
||||
<span class="stat-label">в работе</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ stats.done }}</span>
|
||||
<span class="stat-label">готово</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Header>
|
||||
|
||||
<!-- Доска с колонками и карточками -->
|
||||
<main class="main">
|
||||
<Board
|
||||
ref="boardRef"
|
||||
:active-department="activeDepartment"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
:columns="columns"
|
||||
:cards="cards"
|
||||
@stats-updated="stats = $event"
|
||||
@open-task="openTaskPanel"
|
||||
@create-task="openNewTaskPanel"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Панель редактирования/создания задачи -->
|
||||
<TaskPanel
|
||||
:show="panelOpen"
|
||||
:card="editingCard"
|
||||
:column-id="editingColumnId"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
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'
|
||||
|
||||
// Активный фильтр по отделу (null = все)
|
||||
const activeDepartment = ref(null)
|
||||
// Статистика для шапки
|
||||
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||
// Данные из API
|
||||
const departments = ref([])
|
||||
const labels = ref([])
|
||||
const columns = ref([])
|
||||
const cards = ref([])
|
||||
|
||||
// Загрузка всех данных из API параллельно
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [departmentsData, labelsData, columnsData, cardsData] = await Promise.all([
|
||||
departmentsApi.getAll(),
|
||||
labelsApi.getAll(),
|
||||
columnsApi.getAll(),
|
||||
cardsApi.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
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Выход из системы
|
||||
// Ссылка на компонент Board для вызова его методов
|
||||
const boardRef = ref(null)
|
||||
// Состояние панели редактирования
|
||||
const panelOpen = ref(false)
|
||||
// Редактируемая карточка (null = создание новой)
|
||||
const editingCard = ref(null)
|
||||
// ID колонки для новой/редактируемой карточки
|
||||
const editingColumnId = ref(null)
|
||||
|
||||
// Открыть панель для редактирования существующей задачи
|
||||
const openTaskPanel = ({ card, columnId }) => {
|
||||
editingCard.value = card
|
||||
editingColumnId.value = columnId
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
// Открыть панель для создания новой задачи
|
||||
const openNewTaskPanel = (columnId) => {
|
||||
editingCard.value = null
|
||||
editingColumnId.value = columnId
|
||||
panelOpen.value = true
|
||||
}
|
||||
|
||||
// Закрыть панель и сбросить состояние
|
||||
const closePanel = () => {
|
||||
panelOpen.value = false
|
||||
editingCard.value = null
|
||||
editingColumnId.value = null
|
||||
}
|
||||
|
||||
// Сохранить задачу через Board компонент
|
||||
const handleSaveTask = (taskData) => {
|
||||
boardRef.value?.saveTask(taskData, editingColumnId.value)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Удалить задачу через Board компонент
|
||||
const handleDeleteTask = (cardId) => {
|
||||
boardRef.value?.deleteTask(cardId, editingColumnId.value)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Инициализация при монтировании
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Контейнер приложения */
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
/* Основная область контента */
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
/* Контейнер фильтров */
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Кнопка фильтра */
|
||||
.filter-tag {
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
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;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Активный фильтр */
|
||||
.filter-tag.active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Блок статистики */
|
||||
.header-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Разделитель между статами */
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Основная область с доской */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 0 36px 36px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Стилизация горизонтального скроллбара */
|
||||
.main::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
margin: 0 36px;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 212, 170, 0.4);
|
||||
border-radius: 5px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.main::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 212, 170, 0.6);
|
||||
}
|
||||
</style>
|
||||
223
front_vue/src/views/TeamPage.vue
Normal file
223
front_vue/src/views/TeamPage.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<Sidebar />
|
||||
|
||||
<div class="main-wrapper">
|
||||
<Header title="Команда" subtitle="Наша команда специалистов" />
|
||||
|
||||
<main class="main">
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Загрузка...</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="team-grid">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="team-card"
|
||||
>
|
||||
<div class="card-avatar">
|
||||
<img :src="user.avatar_url" :alt="user.name">
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<h3 class="card-name">{{ user.name }}</h3>
|
||||
<div class="card-meta">
|
||||
<span v-if="user.department" class="card-department">{{ user.department }}</span>
|
||||
<span class="card-username">@{{ user.username }}</span>
|
||||
</div>
|
||||
<a
|
||||
:href="'https://t.me/' + user.telegram.replace('@', '')"
|
||||
target="_blank"
|
||||
class="card-telegram"
|
||||
>
|
||||
<i data-lucide="send"></i>
|
||||
{{ user.telegram }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUpdated } from 'vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import { usersApi } from '../api'
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const data = await usersApi.getAll()
|
||||
if (data.success) {
|
||||
users.value = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки команды:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.main-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 80px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.team-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.team-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-username {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-department {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-telegram {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(0, 136, 204, 0.15);
|
||||
color: #0088cc;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.card-telegram:hover {
|
||||
background: rgba(0, 136, 204, 0.25);
|
||||
}
|
||||
|
||||
.card-telegram i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
10
front_vue/vite.config.js
Normal file
10
front_vue/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user