kin/web/frontend/src/views/ProjectView.vue

1028 lines
45 KiB
Vue
Raw Normal View History

<script setup lang="ts">
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref('')
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('tasks')
// Phases
const phases = ref<Phase[]>([])
const phasesLoading = ref(false)
const phaseError = ref('')
const showReviseModal = ref(false)
const revisePhaseId = ref<number | null>(null)
const reviseComment = ref('')
const reviseError = ref('')
const reviseSaving = ref(false)
const showRejectModal = ref(false)
const rejectPhaseId = ref<number | null>(null)
const rejectReason = ref('')
const rejectError = ref('')
const rejectSaving = ref(false)
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
const startPhaseSaving = ref(false)
const approvePhaseSaving = ref(false)
let phasePollTimer: ReturnType<typeof setInterval> | null = null
function checkAndPollPhases() {
const hasRunning = phases.value.some(ph => ph.task?.status === 'in_progress')
if (hasRunning && !phasePollTimer) {
phasePollTimer = setInterval(async () => {
phases.value = await api.getPhases(props.id).catch(() => phases.value)
if (!phases.value.some(ph => ph.task?.status === 'in_progress')) {
clearInterval(phasePollTimer!)
phasePollTimer = null
}
}, 5000)
} else if (!hasRunning && phasePollTimer) {
clearInterval(phasePollTimer)
phasePollTimer = null
}
}
async function loadPhases() {
phasesLoading.value = true
phaseError.value = ''
try {
phases.value = await api.getPhases(props.id)
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
checkAndPollPhases()
} catch (e: any) {
phaseError.value = e.message
} finally {
phasesLoading.value = false
}
}
async function approvePhase(phaseId: number) {
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
approvePhaseSaving.value = true
try {
await api.approvePhase(phaseId)
await loadPhases()
} catch (e: any) {
phaseError.value = e.message
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
} finally {
approvePhaseSaving.value = false
}
}
async function startPhase() {
startPhaseSaving.value = true
phaseError.value = ''
try {
await api.startPhase(props.id)
await loadPhases()
} catch (e: any) {
phaseError.value = e.message
} finally {
startPhaseSaving.value = false
}
}
function openRevise(phaseId: number) {
revisePhaseId.value = phaseId
reviseComment.value = ''
reviseError.value = ''
showReviseModal.value = true
}
async function submitRevise() {
if (!reviseComment.value.trim()) { reviseError.value = 'Comment required'; return }
reviseSaving.value = true
try {
await api.revisePhase(revisePhaseId.value!, reviseComment.value)
showReviseModal.value = false
await loadPhases()
} catch (e: any) {
reviseError.value = e.message
} finally {
reviseSaving.value = false
}
}
function openReject(phaseId: number) {
rejectPhaseId.value = phaseId
rejectReason.value = ''
rejectError.value = ''
showRejectModal.value = true
}
async function submitReject() {
if (!rejectReason.value.trim()) { rejectError.value = 'Reason required'; return }
rejectSaving.value = true
try {
await api.rejectPhase(rejectPhaseId.value!, rejectReason.value)
showRejectModal.value = false
await loadPhases()
} catch (e: any) {
rejectError.value = e.message
} finally {
rejectSaving.value = false
}
}
function phaseStatusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', active: 'blue', approved: 'green',
rejected: 'red', revising: 'yellow',
}
return m[s] || 'gray'
}
// Filters
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
function initStatusFilter(): string[] {
const q = route.query.status as string
if (q) return q.split(',').filter((s: string) => s)
const stored = localStorage.getItem(`kin-task-statuses-${props.id}`)
if (stored) { try { return JSON.parse(stored) } catch {} }
return []
}
const selectedStatuses = ref<string[]>(initStatusFilter())
const selectedCategory = ref('')
const taskSearch = ref('')
function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s)
if (idx >= 0) selectedStatuses.value.splice(idx, 1)
else selectedStatuses.value.push(s)
}
function clearStatusFilter() {
selectedStatuses.value = []
}
const decisionTypeFilter = ref('')
const decisionSearch = ref('')
// Auto/Review mode (persisted per project)
const autoMode = ref(false)
function loadMode() {
if (project.value?.execution_mode) {
autoMode.value = project.value.execution_mode === 'auto_complete'
} else {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto_complete'
}
}
async function toggleMode() {
autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto_complete' : 'review')
try {
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto_complete' : 'review' })
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto_complete' : 'review' }
} catch (e: any) {
error.value = e.message
}
}
// Autocommit toggle
const autocommit = ref(false)
function loadAutocommit() {
autocommit.value = !!(project.value?.autocommit_enabled)
}
async function toggleAutocommit() {
autocommit.value = !autocommit.value
try {
await api.patchProject(props.id, { autocommit_enabled: autocommit.value })
if (project.value) project.value = { ...project.value, autocommit_enabled: autocommit.value ? 1 : 0 }
} catch (e: any) {
error.value = e.message
autocommit.value = !autocommit.value
}
}
// Audit
const auditLoading = ref(false)
const auditResult = ref<AuditResult | null>(null)
const showAuditModal = ref(false)
const auditApplying = ref(false)
async function runAudit() {
auditLoading.value = true
auditResult.value = null
try {
const res = await api.auditProject(props.id)
auditResult.value = res
showAuditModal.value = true
} catch (e: any) {
error.value = e.message
} finally {
auditLoading.value = false
}
}
async function applyAudit() {
if (!auditResult.value?.already_done?.length) return
auditApplying.value = true
try {
const ids = auditResult.value.already_done.map(t => t.id)
await api.auditApply(props.id, ids)
showAuditModal.value = false
auditResult.value = null
await load()
} catch (e: any) {
error.value = e.message
} finally {
auditApplying.value = false
}
}
// Add task modal
const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS']
const CATEGORY_COLORS: Record<string, string> = {
SEC: 'red', UI: 'purple', API: 'blue', INFRA: 'orange', BIZ: 'green',
DB: 'yellow', ARCH: 'indigo', TEST: 'cyan', PERF: 'pink', DOCS: 'gray',
FIX: 'rose', OBS: 'teal',
}
const showAddTask = ref(false)
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
const taskFormError = ref('')
// Add decision modal
const showAddDecision = ref(false)
const decForm = ref({ type: 'decision', title: '', description: '', category: '', tags: '' })
const decFormError = ref('')
async function load() {
try {
loading.value = true
project.value = await api.project(props.id)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
watch(selectedStatuses, (val) => {
localStorage.setItem(`kin-task-statuses-${props.id}`, JSON.stringify(val))
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true })
watch(() => props.id, () => {
taskSearch.value = ''
})
onMounted(async () => {
await load()
loadMode()
loadAutocommit()
await loadPhases()
})
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
onUnmounted(() => {
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
if (kanbanPollTimer) { clearInterval(kanbanPollTimer); kanbanPollTimer = null }
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
})
const taskCategories = computed(() => {
if (!project.value) return []
const cats = new Set(project.value.tasks.map(t => t.category).filter(Boolean) as string[])
return Array.from(cats).sort()
})
const searchFilteredTasks = computed(() => {
if (!project.value) return []
const q = taskSearch.value.trim().toLowerCase()
if (!q) return project.value.tasks
return project.value.tasks.filter(t => {
if (t.title.toLowerCase().includes(q)) return true
if (t.brief && JSON.stringify(t.brief).toLowerCase().includes(q)) return true
return false
})
})
const filteredTasks = computed(() => {
let tasks = searchFilteredTasks.value
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
return tasks
})
const manualEscalationTasks = computed(() => {
if (!project.value) return []
return project.value.tasks.filter(
t => t.brief?.task_type === 'manual_escalation' && t.status !== 'done' && t.status !== 'cancelled'
)
})
const filteredDecisions = computed(() => {
if (!project.value) return []
let decs = project.value.decisions
if (decisionTypeFilter.value) decs = decs.filter(d => d.type === decisionTypeFilter.value)
if (decisionSearch.value) {
const q = decisionSearch.value.toLowerCase()
decs = decs.filter(d => d.title.toLowerCase().includes(q) || d.description.toLowerCase().includes(q))
}
return decs
})
function taskStatusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', in_progress: 'blue', review: 'purple',
done: 'green', blocked: 'red', decomposed: 'yellow', cancelled: 'gray',
}
return m[s] || 'gray'
}
function decTypeColor(t: string) {
const m: Record<string, string> = {
decision: 'blue', gotcha: 'red', workaround: 'yellow',
rejected_approach: 'gray', convention: 'purple',
}
return m[t] || 'gray'
}
function modTypeColor(t: string) {
const m: Record<string, string> = {
frontend: 'blue', backend: 'green', shared: 'purple', infra: 'orange',
}
return m[t] || 'gray'
}
const decTypes = computed(() => {
if (!project.value) return []
const s = new Set(project.value.decisions.map(d => d.type))
return Array.from(s).sort()
})
async function addTask() {
taskFormError.value = ''
try {
await api.createTask({
project_id: props.id,
title: taskForm.value.title,
priority: taskForm.value.priority,
route_type: taskForm.value.route_type || undefined,
category: taskForm.value.category || undefined,
acceptance_criteria: taskForm.value.acceptance_criteria || undefined,
})
showAddTask.value = false
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
await load()
} catch (e: any) {
taskFormError.value = e.message
}
}
async function runTask(taskId: string, event: Event) {
event.preventDefault()
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
try {
await api.runTask(taskId)
await load()
if (activeTab.value === 'kanban') checkAndPollKanban()
} catch (e: any) {
error.value = e.message
}
}
async function patchTaskField(taskId: string, data: { priority?: number; route_type?: string }) {
try {
const updated = await api.patchTask(taskId, data)
if (project.value) {
const idx = project.value.tasks.findIndex(t => t.id === taskId)
if (idx >= 0) project.value.tasks[idx] = updated
}
} catch (e: any) {
error.value = e.message
}
}
// Kanban
const KANBAN_COLUMNS = [
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
]
const draggingTaskId = ref<string | null>(null)
const dragOverStatus = ref<string | null>(null)
let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
const kanbanTasksByStatus = computed(() => {
const result: Record<string, Task[]> = {}
for (const col of KANBAN_COLUMNS) result[col.status] = []
for (const t of searchFilteredTasks.value) {
if (result[t.status]) result[t.status].push(t)
}
return result
})
function checkAndPollKanban() {
const hasRunning = project.value?.tasks.some(t => t.status === 'in_progress') ?? false
if (hasRunning && !kanbanPollTimer) {
kanbanPollTimer = setInterval(async () => {
project.value = await api.project(props.id).catch(() => project.value)
if (!project.value?.tasks.some(t => t.status === 'in_progress')) {
clearInterval(kanbanPollTimer!)
kanbanPollTimer = null
}
}, 5000)
} else if (!hasRunning && kanbanPollTimer) {
clearInterval(kanbanPollTimer)
kanbanPollTimer = null
}
}
watch(activeTab, (tab) => {
if (tab === 'kanban') {
checkAndPollKanban()
} else if (kanbanPollTimer) {
clearInterval(kanbanPollTimer)
kanbanPollTimer = null
}
})
function onDragStart(event: DragEvent, taskId: string) {
draggingTaskId.value = taskId
event.dataTransfer?.setData('text/plain', taskId)
}
function onDragEnd() {
draggingTaskId.value = null
dragOverStatus.value = null
}
function onDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement
const related = event.relatedTarget as HTMLElement | null
if (!related || !target.contains(related)) dragOverStatus.value = null
}
async function onKanbanDrop(event: DragEvent, newStatus: string) {
dragOverStatus.value = null
const taskId = event.dataTransfer?.getData('text/plain') || draggingTaskId.value
draggingTaskId.value = null
if (!taskId || !project.value) return
const task = project.value.tasks.find(t => t.id === taskId)
if (!task || task.status === newStatus) return
try {
const updated = await api.patchTask(taskId, { status: newStatus })
const idx = project.value.tasks.findIndex(t => t.id === taskId)
if (idx >= 0) project.value.tasks[idx] = updated
checkAndPollKanban()
} catch (e: any) {
error.value = e.message
}
}
async function addDecision() {
decFormError.value = ''
try {
const tags = decForm.value.tags ? decForm.value.tags.split(',').map(s => s.trim()).filter(Boolean) : undefined
await api.createDecision({
project_id: props.id,
type: decForm.value.type,
title: decForm.value.title,
description: decForm.value.description,
category: decForm.value.category || undefined,
tags,
})
showAddDecision.value = false
decForm.value = { type: 'decision', title: '', description: '', category: '', tags: '' }
await load()
} catch (e: any) {
decFormError.value = e.message
}
}
</script>
<template>
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="project">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">&larr; back</router-link>
</div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
<span class="text-gray-400">{{ project.name }}</span>
<Badge :text="project.status" :color="project.status === 'active' ? 'green' : 'gray'" />
<Badge v-if="project.project_type && project.project_type !== 'development'"
:text="project.project_type"
:color="project.project_type === 'operations' ? 'orange' : 'green'" />
</div>
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">
<Badge v-for="t in project.tech_stack" :key="t" :text="t" color="purple" />
</div>
<!-- Path (development / research) -->
<p v-if="project.project_type !== 'operations'" class="text-xs text-gray-600">{{ project.path }}</p>
<!-- SSH info (operations) -->
<div v-if="project.project_type === 'operations'" class="flex flex-wrap gap-3 text-xs text-gray-600">
<span v-if="project.ssh_host">
<span class="text-gray-500">SSH:</span>
<span class="text-orange-400 ml-1">{{ project.ssh_user || 'root' }}@{{ project.ssh_host }}</span>
</span>
<span v-if="project.ssh_key_path">key: {{ project.ssh_key_path }}</span>
<span v-if="project.ssh_proxy_jump">via: {{ project.ssh_proxy_jump }}</span>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-4 border-b border-gray-800">
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
@click="activeTab = tab"
class="px-4 py-2 text-sm border-b-2 transition-colors"
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
: tab === 'decisions' ? project.decisions.length
: tab === 'modules' ? project.modules.length
: project.tasks.length }}
</span>
</button>
</div>
<!-- Tasks Tab -->
<div v-if="activeTab === 'tasks'">
<div class="flex flex-col gap-2 mb-3">
<div class="flex items-center justify-between">
<div class="flex gap-1 flex-wrap items-center">
<button v-for="s in ALL_TASK_STATUSES" :key="s"
:data-status="s"
@click="toggleStatus(s)"
class="px-2 py-0.5 text-xs rounded border transition-colors"
:class="selectedStatuses.includes(s)
? 'bg-blue-900/40 text-blue-300 border-blue-700'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>{{ s }}</button>
<button v-if="selectedStatuses.length" data-action="clear-status" @click="clearStatusFilter"
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div>
<div class="flex gap-2">
<button @click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Auto' : '&#x1F512; Review' }}
</button>
<button @click="toggleAutocommit"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autocommit
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
{{ autocommit ? '&#x2713; Autocommit' : 'Autocommit' }}
</button>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ auditLoading ? 'Auditing...' : 'Audit backlog' }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Task
</button>
</div>
</div>
<!-- Category filter -->
<div v-if="taskCategories.length" class="flex gap-1 flex-wrap items-center">
<button @click="selectedCategory = ''"
class="px-2 py-0.5 text-xs rounded border transition-colors"
:class="!selectedCategory
? 'bg-gray-700/60 text-gray-300 border-gray-600'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>Все</button>
<button v-for="cat in taskCategories" :key="cat"
@click="selectedCategory = cat"
class="px-2 py-0.5 text-xs rounded border transition-colors"
:class="selectedCategory === cat
? 'opacity-100 ring-1 ring-offset-0'
: 'opacity-60 hover:opacity-100'"
>
<Badge :text="cat" :color="CATEGORY_COLORS[cat] || 'gray'" />
</button>
</div>
<!-- Search -->
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск по задачам..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-56 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
</div>
</div>
<!-- Manual escalation tasks -->
<div v-if="manualEscalationTasks.length" class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">&#9888; Требуют ручного решения</span>
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
</div>
<div class="space-y-1">
<router-link v-for="t in manualEscalationTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded text-sm hover:border-orange-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-orange-300 truncate">{{ t.title }}</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">escalated from {{ t.parent_task_id }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
<span v-if="t.brief?.description" class="text-orange-600 truncate max-w-[200px]">{{ t.brief.description }}</span>
<span>pri {{ t.priority }}</span>
</div>
</router-link>
</div>
</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-gray-300 truncate">{{ t.title }}</span>
<span v-if="t.execution_mode === 'auto_complete'"
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
title="Auto mode">&#x1F513;</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
<select
@click.stop
@change.stop="patchTaskField(t.id, { route_type: ($event.target as HTMLSelectElement).value })"
:value="(t.brief as Record<string, string> | null)?.route_type || ''"
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
title="Task type">
<option value=""></option>
<option value="debug">debug</option>
<option value="feature">feature</option>
<option value="refactor">refactor</option>
<option value="hotfix">hotfix</option>
</select>
<select
@click.stop
@change.stop="patchTaskField(t.id, { priority: Number(($event.target as HTMLSelectElement).value) })"
:value="t.priority"
class="bg-gray-900 border border-gray-700 rounded px-1 py-0.5 text-[10px] text-gray-500 cursor-pointer hover:border-gray-500 hover:text-gray-300 transition-colors"
title="Priority">
<option v-for="n in 10" :key="n" :value="n">p{{ n }}</option>
</select>
<button v-if="t.status === 'pending'"
@click="runTask(t.id, $event)"
class="px-2 py-0.5 bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 text-[10px]"
title="Run pipeline">&#9654;</button>
<span v-if="t.status === 'in_progress'"
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse" title="Running"></span>
</div>
</router-link>
</div>
</div>
<!-- Phases Tab -->
<div v-if="activeTab === 'phases'">
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
No research phases. Use "New Project" to start a research workflow.
</div>
<div v-else class="space-y-2">
<div v-for="ph in phases" :key="ph.id"
class="px-4 py-3 border rounded transition-colors"
:class="{
'border-blue-700 bg-blue-950/20': ph.status === 'active',
'border-green-800 bg-green-950/10': ph.status === 'approved',
'border-red-800 bg-red-950/10': ph.status === 'rejected',
'border-yellow-700 bg-yellow-950/10': ph.status === 'revising',
'border-gray-800': ph.status === 'pending',
}">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 w-5 text-right">{{ ph.phase_order + 1 }}.</span>
<Badge :text="ph.status" :color="phaseStatusColor(ph.status)" />
<span class="text-sm font-medium text-gray-200">{{ ph.role.replace(/_/g, ' ') }}</span>
<span v-if="ph.revise_count" class="text-xs text-yellow-500">(revise ×{{ ph.revise_count }})</span>
</div>
<div class="flex items-center gap-2">
<router-link v-if="ph.task_id"
:to="`/task/${ph.task_id}`"
class="text-xs text-blue-400 hover:text-blue-300 no-underline">
{{ ph.task_id }}
</router-link>
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
<!-- Running indicator -->
<span v-if="ph.task?.status === 'in_progress'"
class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"
title="Agent running..."></span>
<!-- Start Research button: phase ready but task not started yet -->
<button
v-if="(ph.status === 'active' || ph.status === 'revising') && ph.task?.status === 'pending'"
@click="startPhase"
:disabled="startPhaseSaving"
class="px-2 py-0.5 text-xs bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ startPhaseSaving ? 'Starting...' : (ph.status === 'revising' ? 'Re-run' : 'Start Research') }}
</button>
<!-- Approve/Revise/Reject: only when agent submitted work for review (decision #78) -->
<template v-if="(ph.status === 'active' || ph.status === 'revising') && ph.task?.status === 'review'">
<button @click="approvePhase(ph.id)" :disabled="approvePhaseSaving"
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900 disabled:opacity-50">
{{ approvePhaseSaving ? '...' : 'Approve' }}
</button>
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
<button @click="openRevise(ph.id)" :disabled="approvePhaseSaving"
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900 disabled:opacity-50">
Revise
</button>
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
<button @click="openReject(ph.id)" :disabled="approvePhaseSaving"
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900 disabled:opacity-50">
Reject
</button>
</template>
</div>
</div>
<div v-if="ph.task?.title" class="mt-1 text-xs text-gray-500 ml-7">{{ ph.task.title }}</div>
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
<div v-if="ph.revise_comment" class="mt-1 text-xs text-yellow-600 ml-7">&#x27A4; {{ ph.revise_comment }}</div>
</div>
</div>
<!-- Revise Modal -->
<Modal v-if="showReviseModal" title="Request Revision" @close="showReviseModal = false">
<div class="space-y-3">
<textarea v-model="reviseComment" placeholder="Что нужно доработать?" rows="4"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<p v-if="reviseError" class="text-red-400 text-xs">{{ reviseError }}</p>
<button @click="submitRevise" :disabled="reviseSaving"
class="w-full py-2 bg-yellow-900/50 text-yellow-400 border border-yellow-800 rounded text-sm hover:bg-yellow-900 disabled:opacity-50">
{{ reviseSaving ? 'Saving...' : 'Send for Revision' }}
</button>
</div>
</Modal>
<!-- Reject Modal -->
<Modal v-if="showRejectModal" title="Reject Phase" @close="showRejectModal = false">
<div class="space-y-3">
<textarea v-model="rejectReason" placeholder="Причина отклонения" rows="3"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<p v-if="rejectError" class="text-red-400 text-xs">{{ rejectError }}</p>
<button @click="submitReject" :disabled="rejectSaving"
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900 disabled:opacity-50">
{{ rejectSaving ? 'Saving...' : 'Reject' }}
</button>
</div>
</Modal>
</div>
<!-- Decisions Tab -->
<div v-if="activeTab === 'decisions'">
<div class="flex items-center justify-between mb-3">
<div class="flex gap-2">
<select v-model="decisionTypeFilter"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
<option value="">All types</option>
<option v-for="t in decTypes" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="decisionSearch" placeholder="Search..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48" />
</div>
<button @click="showAddDecision = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Decision
</button>
</div>
<div v-if="filteredDecisions.length === 0" class="text-gray-600 text-sm">No decisions.</div>
<div v-else class="space-y-2">
<div v-for="d in filteredDecisions" :key="d.id"
class="px-3 py-2 border border-gray-800 rounded hover:border-gray-700">
<div class="flex items-center gap-2 mb-1">
<span class="text-gray-600 text-xs">#{{ d.id }}</span>
<Badge :text="d.type" :color="decTypeColor(d.type)" />
<Badge v-if="d.category" :text="d.category" color="gray" />
</div>
<div class="text-sm text-gray-300">{{ d.title }}</div>
<div v-if="d.description !== d.title" class="text-xs text-gray-500 mt-1">{{ d.description }}</div>
<div v-if="d.tags?.length" class="flex gap-1 mt-1">
<Badge v-for="tag in d.tags" :key="tag" :text="tag" color="purple" />
</div>
</div>
</div>
</div>
<!-- Modules Tab -->
<div v-if="activeTab === 'modules'">
<div v-if="project.modules.length === 0" class="text-gray-600 text-sm">No modules.</div>
<div v-else class="space-y-1">
<div v-for="m in project.modules" :key="m.id"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700">
<div class="flex items-center gap-2">
<span class="text-gray-300 font-medium">{{ m.name }}</span>
<Badge :text="m.type" :color="modTypeColor(m.type)" />
</div>
<div class="flex items-center gap-3 text-xs text-gray-600">
<span>{{ m.path }}</span>
<span v-if="m.owner_role">{{ m.owner_role }}</span>
<span v-if="m.description">{{ m.description }}</span>
</div>
</div>
</div>
</div>
<!-- Kanban Tab -->
<div v-if="activeTab === 'kanban'" class="pb-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
</div>
<div class="flex gap-2">
<button @click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Авто' : '&#x1F512; Review' }}
</button>
<button @click="toggleAutocommit"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autocommit
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
{{ autocommit ? '&#x2713; Автокомит' : 'Автокомит' }}
</button>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ auditLoading ? 'Auditing...' : 'Аудит' }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Тас
</button>
</div>
</div>
<div class="overflow-x-auto">
<div class="flex gap-3 w-full">
<div v-for="col in KANBAN_COLUMNS" :key="col.status" class="flex-1 min-w-[12rem] flex flex-col gap-2">
<!-- Column header -->
<div class="flex items-center gap-2 px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide" :class="col.headerClass">{{ col.label }}</span>
<span class="text-xs text-gray-600">({{ kanbanTasksByStatus[col.status]?.length || 0 }})</span>
</div>
<!-- Drop zone -->
<div
class="flex flex-col gap-1.5 min-h-24 rounded p-1.5 border transition-colors"
:class="[col.bgClass, dragOverStatus === col.status ? 'border-blue-600' : 'border-transparent']"
@dragover.prevent
@dragenter.prevent="dragOverStatus = col.status"
@dragleave="onDragLeave"
@drop.prevent="onKanbanDrop($event, col.status)">
<router-link
v-for="t in kanbanTasksByStatus[col.status]" :key="t.id"
:to="`/task/${t.id}`"
draggable="true"
@dragstart="onDragStart($event, t.id)"
@dragend="onDragEnd"
class="block px-2.5 py-2 bg-gray-900 border border-gray-800 rounded text-xs hover:border-gray-600 no-underline cursor-grab active:cursor-grabbing transition-colors select-none"
:class="draggingTaskId === t.id ? 'opacity-40' : ''">
<div class="text-gray-500 mb-1 text-[10px]">{{ t.id }}</div>
<div class="text-gray-300 leading-snug mb-1.5">{{ t.title }}</div>
<div class="flex items-center gap-1 flex-wrap">
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-gray-600 text-[10px] ml-auto">p{{ t.priority }}</span>
<span v-if="t.status === 'in_progress'" class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
</div>
</router-link>
<div v-if="!kanbanTasksByStatus[col.status]?.length" class="text-xs text-gray-700 text-center py-6"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Add Task Modal -->
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
<form @submit.prevent="addTask" class="space-y-3">
<input v-model="taskForm.title" placeholder="Task title" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<select v-model="taskForm.category"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="">No category (old format: PROJ-001)</option>
<option v-for="cat in TASK_CATEGORIES" :key="cat" :value="cat">{{ cat }}</option>
</select>
<select v-model="taskForm.route_type"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="">No type</option>
<option value="debug">debug</option>
<option value="feature">feature</option>
<option value="refactor">refactor</option>
<option value="hotfix">hotfix</option>
</select>
<input v-model.number="taskForm.priority" type="number" min="1" max="10" placeholder="Priority"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div>
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
<textarea v-model="taskForm.acceptance_criteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
</div>
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
<button type="submit"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
Create
</button>
</form>
</Modal>
<!-- Add Decision Modal -->
<Modal v-if="showAddDecision" title="Add Decision" @close="showAddDecision = false">
<form @submit.prevent="addDecision" class="space-y-3">
<select v-model="decForm.type" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="decision">decision</option>
<option value="gotcha">gotcha</option>
<option value="workaround">workaround</option>
<option value="convention">convention</option>
<option value="rejected_approach">rejected_approach</option>
</select>
<input v-model="decForm.title" placeholder="Title" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-model="decForm.description" placeholder="Description" rows="3" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
<input v-model="decForm.category" placeholder="Category (e.g. ui, api, security)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="decForm.tags" placeholder="Tags (comma-separated)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<p v-if="decFormError" class="text-red-400 text-xs">{{ decFormError }}</p>
<button type="submit"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
Create
</button>
</form>
</Modal>
<!-- Audit Modal -->
<Modal v-if="showAuditModal && auditResult" title="Backlog Audit Results" @close="showAuditModal = false">
<div v-if="!auditResult.success" class="text-red-400 text-sm">
Audit failed: {{ auditResult.error }}
</div>
<div v-else class="space-y-4">
<div v-if="auditResult.already_done?.length">
<h3 class="text-sm font-semibold text-green-400 mb-2">Already done ({{ auditResult.already_done.length }})</h3>
<div v-for="item in auditResult.already_done" :key="item.id"
class="px-3 py-2 border border-green-900/50 rounded text-xs mb-1">
<span class="text-green-400 font-medium">{{ item.id }}</span>
<span class="text-gray-400 ml-2">{{ item.reason }}</span>
</div>
</div>
<div v-if="auditResult.still_pending?.length">
<h3 class="text-sm font-semibold text-gray-400 mb-2">Still pending ({{ auditResult.still_pending.length }})</h3>
<div v-for="item in auditResult.still_pending" :key="item.id"
class="px-3 py-2 border border-gray-800 rounded text-xs mb-1">
<span class="text-gray-300 font-medium">{{ item.id }}</span>
<span class="text-gray-500 ml-2">{{ item.reason }}</span>
</div>
</div>
<div v-if="auditResult.unclear?.length">
<h3 class="text-sm font-semibold text-yellow-400 mb-2">Unclear ({{ auditResult.unclear.length }})</h3>
<div v-for="item in auditResult.unclear" :key="item.id"
class="px-3 py-2 border border-yellow-900/50 rounded text-xs mb-1">
<span class="text-yellow-400 font-medium">{{ item.id }}</span>
<span class="text-gray-400 ml-2">{{ item.reason }}</span>
</div>
</div>
<div v-if="auditResult.cost_usd || auditResult.duration_seconds" class="text-xs text-gray-600">
<span v-if="auditResult.duration_seconds">{{ auditResult.duration_seconds }}s</span>
<span v-if="auditResult.cost_usd" class="ml-2">${{ auditResult.cost_usd?.toFixed(4) }}</span>
</div>
<button v-if="auditResult.already_done?.length" @click="applyAudit" :disabled="auditApplying"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
{{ auditApplying ? 'Applying...' : `Mark ${auditResult.already_done.length} tasks as done` }}
</button>
</div>
</Modal>
</div>
</template>