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

784 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult, type Phase } 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'>('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)
async function loadPhases() {
phasesLoading.value = true
phaseError.value = ''
try {
phases.value = await api.getPhases(props.id)
} catch (e: any) {
phaseError.value = e.message
} finally {
phasesLoading.value = false
}
}
async function approvePhase(phaseId: number) {
try {
await api.approvePhase(phaseId)
await loadPhases()
} catch (e: any) {
phaseError.value = e.message
}
}
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('')
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'
} else {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
}
}
async function toggleMode() {
autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
try {
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto' : '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: '' })
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 })
onMounted(async () => {
await load()
loadMode()
loadAutocommit()
await loadPhases()
})
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 filteredTasks = computed(() => {
if (!project.value) return []
let tasks = project.value.tasks
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,
})
showAddTask.value = false
taskForm.value = { title: '', priority: 5, route_type: '', category: '' }
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()
} 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
}
}
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'] 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.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
: project.modules.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>
</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'"
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>
<template v-if="ph.status === 'active'">
<button @click="approvePhase(ph.id)"
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900">
Approve
</button>
<button @click="openRevise(ph.id)"
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900">
Revise
</button>
<button @click="openReject(ph.id)"
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900">
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>
</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>
<!-- 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" />
<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>