feat: integrate coaching frameworks, Eisenhower matrix, Gantt chart, and Goal Validation test

This commit is contained in:
2026-05-25 07:59:09 +05:00
parent 7161523d01
commit bb971e665f
12 changed files with 2789 additions and 229 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,266 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
<!-- Modal Header -->
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
<span>🧙‍♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
</h2>
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 bg-slate-950/40">
<button
@click="wizardType = 'smart'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🎯 SMART Цель
</button>
<button
@click="wizardType = 'grow'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🌱 GROW Сессия
</button>
<button
@click="wizardType = 'dilts'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📐 Пирамида Дилтса
</button>
<button
@click="wizardType = 'eisenhower'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📊 Матрица Эйзенхауэра
</button>
</div>
<!-- Modal Body (Forms) -->
<div class="p-6 overflow-y-auto flex-1 space-y-4">
<!-- S.M.A.R.T Form -->
<div v-if="wizardType === 'smart'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
</div>
</div>
<!-- G.R.O.W Form -->
<div v-if="wizardType === 'grow'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь?&#10;Какой результат я хочу получить?&#10;Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить?&#10;Какие ресурсы мне нужны?&#10;Кто мне может помочь?&#10;А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация?&#10;Какие препятствия я вижу?&#10;Что мне мешает начать?&#10;Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять?&#10;Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Dilts Form -->
<div v-if="wizardType === 'dilts'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Eisenhower Form -->
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
Отмена
</button>
<button
@click="submitManual"
:disabled="isSubmitting"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
>
Создать вручную
</button>
<button
@click="submitAI"
:disabled="isSubmitting"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
>
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
<span v-else>✨</span>
Оптимизировать через AI
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
isSubmitting: { type: Boolean, default: false }
})
const emit = defineEmits([
'close',
'submit-smart-manual',
'submit-smart-ai',
'submit-grow-manual',
'submit-grow-ai',
'submit-dilts-manual',
'submit-dilts-ai',
'submit-eisenhower-manual',
'submit-eisenhower-ai'
])
const wizardType = ref('smart')
const smartForm = ref({
specific: '',
measurable: '',
achievable: '',
relevant: '',
deadline: ''
})
const growForm = ref({
goal: '',
reality: '',
options: '',
wayForward: ''
})
const diltsForm = ref({
mission: '',
identity: '',
values: '',
skills: '',
actions: '',
environment: ''
})
const eisenhowerForm = ref({
q1: '',
q2: '',
q3: '',
q4: ''
})
const resetForms = () => {
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
}
const submitManual = () => {
if (wizardType.value === 'smart') {
emit('submit-smart-manual', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
emit('submit-grow-manual', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
emit('submit-dilts-manual', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
}
resetForms()
}
const submitAI = () => {
if (wizardType.value === 'smart') {
if (!smartForm.value.specific.trim()) {
alert('Пожалуйста, введите хотя бы описание цели!')
return
}
emit('submit-smart-ai', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
if (!growForm.value.goal.trim()) {
alert('Пожалуйста, введите хотя бы цель (Goal)!')
return
}
emit('submit-grow-ai', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
if (!diltsForm.value.mission.trim()) {
alert('Пожалуйста, введите хотя бы описание миссии!')
return
}
emit('submit-dilts-ai', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
return
}
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
}
}
// Expose reset in case parent wants to reset from outside
defineExpose({ resetForms })
</script>
+318
View File
@@ -0,0 +1,318 @@
<template>
<div
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
:class="[isOpen ? 'h-80' : 'h-10']"
>
<!-- Handle / Toggle Bar -->
<div
@click="isOpen = !isOpen"
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
>
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
<span>📅</span>
<span>Диаграмма Ганта</span>
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
Задач: {{ tasks.length }}
</span>
</div>
<div class="text-slate-400 text-xs">
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
</div>
</div>
<!-- Timeline Body -->
<div
v-if="isOpen"
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
>
<!-- Task Titles Sidebar (Left) -->
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
Задача
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
<div
v-for="task in tasks"
:key="task.id"
@click="$emit('select-node', task.id)"
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
title="Кликните для фокусировки на холсте"
>
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
</div>
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
Нет задач на холсте
</div>
</div>
</div>
<!-- Timeline Chart (Right) -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<!-- Date Columns Header -->
<div
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
ref="timelineHeader"
@scroll="syncScrollLeft"
>
<div
v-for="(day, idx) in timelineDays"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
:style="{ width: `${dayWidth}px` }"
>
<span>{{ day.dayOfWeek }}</span>
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
</div>
</div>
<!-- Task Bars Body -->
<div
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
ref="timelineBody"
@scroll="syncScrollRight"
>
<div
v-for="task in tasks"
:key="task.id"
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
>
<!-- Timeline Grid Background Lines -->
<div class="absolute inset-0 flex pointer-events-none">
<div
v-for="idx in timelineDays.length"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0"
:style="{ width: `${dayWidth}px` }"
></div>
</div>
<!-- Task Gantt Bar -->
<div
v-if="hasValidDates(task)"
@click="$emit('select-node', task.id)"
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
:class="getBarClasses(task)"
:style="getBarStyles(task)"
>
<span class="truncate">{{ task.data.label }}</span>
<!-- Tooltip on hover -->
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
С: {{ formatDate(task.data.start_date) }}<br/>
По: {{ formatDate(task.data.alarm) }}
</div>
</div>
</div>
</div>
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
Создайте задачи и задайте им даты начала/дедлайны
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
elements: { type: Array, default: () => [] }
})
defineEmits(['select-node'])
const isOpen = ref(false)
const dayWidth = 60 // Width of one day column in pixels
const timelineHeader = ref(null)
const timelineBody = ref(null)
// Scroll synchronization between header and body
const syncScrollLeft = (e) => {
if (timelineBody.value) {
timelineBody.value.scrollLeft = e.target.scrollLeft
}
}
const syncScrollRight = (e) => {
if (timelineHeader.value) {
timelineHeader.value.scrollLeft = e.target.scrollLeft
}
}
// Filter tasks
const tasks = computed(() => {
return props.elements.filter(el => el.type === 'task')
})
// Calculate overall min/max dates
const dateRange = computed(() => {
let minDate = new Date()
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
const taskDates = []
tasks.value.forEach(t => {
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
})
if (taskDates.length > 0) {
minDate = new Date(Math.min(...taskDates))
maxDate = new Date(Math.max(...taskDates))
}
// Add 2 days padding to sides
minDate.setDate(minDate.getDate() - 2)
maxDate.setDate(maxDate.getDate() + 5)
// Cap timeline length at 30 days to prevent rendering overflow
const diffTime = Math.abs(maxDate - minDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays > 30) {
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
}
// Reset to midnight
minDate.setHours(0, 0, 0, 0)
maxDate.setHours(23, 59, 59, 999)
return { minDate, maxDate }
})
// Generate list of days for timeline header
const timelineDays = computed(() => {
const days = []
const current = new Date(dateRange.value.minDate)
const end = dateRange.value.maxDate
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
while (current <= end) {
days.push({
date: new Date(current),
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
dayOfWeek: ruDays[current.getDay()]
})
current.setDate(current.getDate() + 1)
}
return days
})
// Check if task has valid start and end date
const hasValidDates = (task) => {
return !!(task.data.start_date || task.data.alarm)
}
// Format single date for tooltips
const formatDate = (dateStr) => {
if (!dateStr) return 'Не указано'
const date = new Date(dateStr)
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
const getBarStyles = (task) => {
const minTime = dateRange.value.minDate.getTime()
const maxTime = dateRange.value.maxDate.getTime()
const totalDuration = maxTime - minTime
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
if (start > end) {
// Correct order if user reversed dates
const tmp = start
start = end
end = tmp
}
// Boundary checks
start = Math.max(start, minTime)
end = Math.min(end, maxTime)
const leftPercent = ((start - minTime) / totalDuration) * 100
const widthPercent = ((end - start) / totalDuration) * 100
// Calculate actual pixel offsets based on timeline width
const totalDays = timelineDays.value.length
const totalPixels = totalDays * dayWidth
const leftPx = (leftPercent / 100) * totalPixels
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
return {
left: `${leftPx}px`,
width: `${widthPx}px`
}
}
// Style colors based on task status
const getBarClasses = (task) => {
const state = task.data.state || 'todo'
switch (state) {
case 'completed':
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
case 'in_progress':
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
case 'review':
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
case 'blocked':
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
default:
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
}
}
const getStatusIcon = (state) => {
switch (state) {
case 'completed': return '✅'
case 'in_progress': return '⏳'
case 'review': return '👀'
case 'blocked': return '⛔'
default: return '📋'
}
}
const getStatusLabel = (state) => {
switch (state) {
case 'completed': return 'Завершено'
case 'in_progress': return 'В работе'
case 'review': return 'На проверке'
case 'blocked': return 'Заблокировано'
default: return 'К выполнению'
}
}
const getPriorityLabel = (priority) => {
switch (priority) {
case 'high': return 'Высокий'
case 'low': return 'Низкий'
default: return 'Средний'
}
}
</script>
<style scoped>
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20 font-sans">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
<div class="flex items-center gap-2">
<span>{{ nt.icon }}</span>
<div>
<div class="font-medium text-slate-200">{{ nt.name }}</div>
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
userRole: { type: String, default: 'viewer' }
})
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
]
const onDragStart = (event, nodeType) => {
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
}
</script>
@@ -0,0 +1,291 @@
<template>
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20 font-sans">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div v-if="node" class="space-y-3">
<!-- SMART & GROW AI Coaching tools -->
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-indigo-500/30 rounded-lg space-y-2 mb-2">
<div class="text-[10px] uppercase font-bold text-indigo-400 flex items-center gap-1">
<span>✨</span> AI Коуч-Ассистент
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
@click="$emit('apply-smart-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Преобразовать цель в формат SMART при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'smart'" class="animate-spin text-xs">🌀</span>
<span v-else>🎯</span>
<span>SMART</span>
</button>
<button
@click="$emit('apply-grow-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-teal-650 hover:bg-teal-600 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Построить цепочку GROW для проблемы при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'grow'" class="animate-spin text-xs">🌀</span>
<span v-else>🌱</span>
<span>GROW</span>
</button>
<button
@click="$emit('apply-dilts-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Разложить проблему по логическим уровням Дилтса при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'dilts'" class="animate-spin text-xs">🌀</span>
<span v-else>📐</span>
<span>Дилтс</span>
</button>
<button
@click="$emit('apply-eisenhower-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Разложить задачи по матрице Эйзенхауэра при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'eisenhower'" class="animate-spin text-xs">🌀</span>
<span v-else>📊</span>
<span>Эйзенхауэр</span>
</button>
</div>
</div>
<!-- Goal Test Section -->
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-emerald-500/30 rounded-lg space-y-2 mb-2">
<div class="text-[10px] uppercase font-bold text-emerald-400 flex items-center justify-between cursor-pointer select-none" @click="showGoalTest = !showGoalTest">
<span class="flex items-center gap-1">⚖️ Тест истинности цели</span>
<span>{{ showGoalTest ? '▼' : '▲' }}</span>
</div>
<div v-if="showGoalTest" class="space-y-2 pt-1.5 border-t border-slate-800">
<div v-for="(q, key) in goalTestQuestions" :key="key" class="space-y-1">
<div class="flex justify-between items-center text-[9px] text-slate-400 font-medium leading-tight">
<span :title="q.full" class="cursor-help border-b border-dashed border-slate-600">{{ q.short }}</span>
<span class="text-emerald-400 font-bold shrink-0 ml-1">{{ goalTestScores[key] }}/10</span>
</div>
<input v-model.number="goalTestScores[key]" type="range" min="1" max="10" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
</div>
<div class="pt-2 border-t border-slate-800 flex flex-col gap-1.5">
<div class="flex justify-between items-center text-xs">
<span class="text-slate-400 font-semibold">Итоговый балл:</span>
<span class="font-bold" :class="goalTestTotalScore >= 60 ? 'text-emerald-400' : 'text-red-400'">
{{ goalTestTotalScore }} / 80
</span>
</div>
<div class="text-[9px] p-1.5 rounded leading-normal border" :class="goalTestTotalScore >= 60 ? 'bg-emerald-950/40 text-emerald-300 border-emerald-800/30' : 'bg-red-950/40 text-red-300 border-red-800/30'">
{{ goalTestTotalScore >= 60 ? '🟢 Цель истинно Ваша! Отличная мотивация.' : '🔴 Внимание! Возможны проблемы с мотивацией (менее 60 баллов).' }}
</div>
<button @click="writeGoalTestToNode" class="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-[10px] font-bold transition">
✍️ Записать результат в узел
</button>
</div>
</div>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
<select v-model="node.type" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
{{ nt.icon }} {{ nt.name }}
</option>
</select>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
<input v-model="node.data.label" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="node.data.text !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
<textarea v-model="node.data.text" @blur="handleChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div v-if="node.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
<input v-model="node.data.file" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
<select v-model="node.data.state" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="todo">📋 К выполнению (To Do)</option>
<option value="in_progress">⏳ В работе (In Progress)</option>
<option value="review">👀 На проверке (Review)</option>
<option value="completed">✅ Завершено (Done)</option>
<option value="blocked">⛔ Заблокировано (Blocked)</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
<select v-model="node.data.priority" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="low">🧊 Низкий</option>
<option value="medium">⚡ Средний</option>
<option value="high">🔥 Высокий</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
<select v-model="node.data.assigned_to" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="">Не назначено</option>
<option v-for="u in usersList" :key="u.username" :value="u.username">
{{ u.displayName || u.username }}
</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дата начала</label>
<input v-model="node.data.start_date" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
<input v-model="node.data.alarm" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="node.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
<input v-model="node.data.url" @blur="handleChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="node.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
<select v-model="node.data.method" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<div v-if="node.type === 'ai'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div v-if="node.data.script_id !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
<input v-model="node.data.script_id" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
</div>
<div v-if="node.type === 'file' || node.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
<select v-model="node.data.file" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none">
<option value="">Не выбрано</option>
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
🗺️ {{ c.name || c.id }}
</option>
</select>
</div>
<div v-if="node.data.prompt !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
<input v-model.number="node.data.width" @blur="handleChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
<input v-model.number="node.data.height" @blur="handleChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
</div>
<div v-else class="text-xs text-slate-500 text-center mt-8">
Выберите узел для редактирования
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
node: { type: Object, default: null },
usersList: { type: Array, default: () => [] },
canvasesList: { type: Array, default: () => [] },
isCoaching: { type: Boolean, default: false },
currentCoachAction: { type: String, default: '' }
})
const emit = defineEmits(['change', 'apply-smart-ai', 'apply-grow-ai', 'apply-dilts-ai', 'apply-eisenhower-ai'])
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝' },
{ type: 'file', name: 'Файл', icon: '📄' },
{ type: 'task', name: 'Задача', icon: '✅' },
{ type: 'ai', name: 'AI Узел', icon: '🤖' },
{ type: 'webhook', name: 'Webhook', icon: '🔗' },
{ type: 'git', name: 'Git Синхр', icon: '🔄' }
]
const handleChange = () => {
emit('change')
}
// Goal Validation Test logic
const showGoalTest = ref(false)
const goalTestScores = ref({
q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5
})
const goalTestQuestions = {
q1: { short: '1. Видение успеха', full: 'Насколько легко и с удовольствием я вижу себя, достигнувшим цель?' },
q2: { short: '2. Страсть к процессу', full: 'Эта цель вызывает у меня страсть сама по себе? Мне нравится процесс ее достижения? (Без учета внешних наград)' },
q3: { short: '3. Доп. польза', full: 'Достижение этой цели принесет мне дополнительную пользу в жизни/других сферах?' },
q4: { short: '4. Баланс жизни', full: 'Смогу ли я достигнуть эту цель, не обделяя другие сферы жизни (время, силы, здоровье)?' },
q5: { short: '5. Радость сразу', full: 'Я смогу получать радость и пользу от достижения цели сразу, по ходу процесса?' },
q6: { short: '6. Зона контроля', full: 'Действия по достижению этой цели находятся в рамках моей личной собственной деятельности?' },
q7: { short: '7. Поддержка окружения', full: 'Мое окружение будет поддерживать меня и мои усилия?' },
q8: { short: '8. Ресурсы и автономия', full: 'У меня есть достаточно ресурсов для достижения? Достижима ли она без посторонней помощи?' }
}
const goalTestTotalScore = computed(() => {
return Object.values(goalTestScores.value).reduce((a, b) => a + b, 0)
})
// Watch selected node and try to parse existing test scores if they exist
watch(() => props.node?.id, () => {
showGoalTest.value = false
// Reset scores to default
goalTestScores.value = { q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5 }
// Try to parse existing score from node text if present
if (props.node?.data?.text) {
const text = props.node.data.text
const match = text.match(/Результат теста цели: (\d+)\/80/)
if (match) {
const qMatches = {
q1: text.match(/Видение успеха:\s*(\d+)/),
q2: text.match(/Страсть к процессу:\s*(\d+)/),
q3: text.match(/Доп. польза:\s*(\d+)/) || text.match(/Дополнительная польза:\s*(\d+)/),
q4: text.match(/Баланс жизни:\s*(\d+)/),
q5: text.match(/Радость сразу:\s*(\d+)/),
q6: text.match(/Зона контроля:\s*(\d+)/) || text.match(/Личная зона контроля:\s*(\d+)/),
q7: text.match(/Поддержка окружения:\s*(\d+)/),
q8: text.match(/Ресурсы и автономия:\s*(\d+)/) || text.match(/Хватает ресурсов:\s*(\d+)/)
}
for (const [key, m] of Object.entries(qMatches)) {
if (m) goalTestScores.value[key] = parseInt(m[1])
}
showGoalTest.value = true
}
}
}, { immediate: true })
const writeGoalTestToNode = () => {
if (!props.node) return
const text = props.node.data.text || props.node.data.label || ''
// Strip previous test results if present
let cleanText = text.split('\n\n---\n### ⚖️ Результат теста цели:')[0]
cleanText = cleanText.split('\n---\n### ⚖️ Результат теста цели:')[0]
const verdict = goalTestTotalScore.value >= 60 ? 'Ваша цель' : 'Чужая цель / Риск потери мотивации'
const testMarkdown = `\n\n---\n### ⚖️ Результат теста цели: ${goalTestTotalScore.value}/80 (${verdict})\n` +
`* **Видение успеха:** ${goalTestScores.value.q1}/10\n` +
`* **Страсть к процессу:** ${goalTestScores.value.q2}/10\n` +
`* **Доп. польза:** ${goalTestScores.value.q3}/10\n` +
`* **Баланс жизни:** ${goalTestScores.value.q4}/10\n` +
`* **Радость сразу:** ${goalTestScores.value.q5}/10\n` +
`* **Зона контроля:** ${goalTestScores.value.q6}/10\n` +
`* **Поддержка окружения:** ${goalTestScores.value.q7}/10\n` +
`* **Ресурсы и автономия:** ${goalTestScores.value.q8}/10`
props.node.data.text = cleanText + testMarkdown
handleChange()
}
</script>