401 lines
19 KiB
Vue
401 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { api, type Project, type CostEntry } from '../api'
|
||
import Badge from '../components/Badge.vue'
|
||
import Modal from '../components/Modal.vue'
|
||
|
||
const projects = ref<Project[]>([])
|
||
const costs = ref<CostEntry[]>([])
|
||
const loading = ref(true)
|
||
const error = ref('')
|
||
|
||
// Add project modal
|
||
const showAdd = ref(false)
|
||
const form = ref({
|
||
id: '', name: '', path: '', tech_stack: '', priority: 5,
|
||
project_type: 'development', ssh_host: '', ssh_user: 'root', ssh_key_path: '', ssh_proxy_jump: '',
|
||
})
|
||
const formError = ref('')
|
||
|
||
// Bootstrap modal
|
||
const showBootstrap = ref(false)
|
||
const bsForm = ref({ id: '', name: '', path: '' })
|
||
const bsError = ref('')
|
||
const bsResult = ref('')
|
||
|
||
// New Project with Research modal
|
||
const RESEARCH_ROLES = [
|
||
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
|
||
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
|
||
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
|
||
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
|
||
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
|
||
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
|
||
]
|
||
const showNewProject = ref(false)
|
||
const npForm = ref({
|
||
id: '', name: '', path: '', description: '', tech_stack: '', priority: 5, language: 'ru',
|
||
})
|
||
const npRoles = ref<string[]>(['business_analyst', 'market_researcher', 'tech_researcher'])
|
||
const npError = ref('')
|
||
const npSaving = ref(false)
|
||
|
||
async function load() {
|
||
try {
|
||
loading.value = true
|
||
;[projects.value, costs.value] = await Promise.all([api.projects(), api.cost(7)])
|
||
} catch (e: any) {
|
||
error.value = e.message
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
let dashPollTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
onMounted(async () => {
|
||
await load()
|
||
// Poll if there are running tasks
|
||
checkAndPoll()
|
||
})
|
||
|
||
function checkAndPoll() {
|
||
const hasRunning = projects.value.some(p => p.active_tasks > 0)
|
||
if (hasRunning && !dashPollTimer) {
|
||
dashPollTimer = setInterval(load, 5000)
|
||
} else if (!hasRunning && dashPollTimer) {
|
||
clearInterval(dashPollTimer)
|
||
dashPollTimer = null
|
||
}
|
||
}
|
||
|
||
const costMap = computed(() => {
|
||
const m: Record<string, number> = {}
|
||
for (const c of costs.value) m[c.project_id] = c.total_cost_usd
|
||
return m
|
||
})
|
||
|
||
const totalCost = computed(() => costs.value.reduce((s, c) => s + c.total_cost_usd, 0))
|
||
|
||
function statusColor(s: string) {
|
||
if (s === 'active') return 'green'
|
||
if (s === 'paused') return 'yellow'
|
||
if (s === 'maintenance') return 'orange'
|
||
return 'gray'
|
||
}
|
||
|
||
async function addProject() {
|
||
formError.value = ''
|
||
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
|
||
formError.value = 'SSH host is required for operations projects'
|
||
return
|
||
}
|
||
if (form.value.project_type !== 'operations' && !form.value.path) {
|
||
formError.value = 'Path is required'
|
||
return
|
||
}
|
||
try {
|
||
const ts = form.value.tech_stack ? form.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||
const payload: Parameters<typeof api.createProject>[0] = {
|
||
id: form.value.id,
|
||
name: form.value.name,
|
||
tech_stack: ts,
|
||
priority: form.value.priority,
|
||
project_type: form.value.project_type,
|
||
}
|
||
if (form.value.project_type !== 'operations') {
|
||
payload.path = form.value.path
|
||
} else {
|
||
payload.path = ''
|
||
if (form.value.ssh_host) payload.ssh_host = form.value.ssh_host
|
||
if (form.value.ssh_user) payload.ssh_user = form.value.ssh_user
|
||
if (form.value.ssh_key_path) payload.ssh_key_path = form.value.ssh_key_path
|
||
if (form.value.ssh_proxy_jump) payload.ssh_proxy_jump = form.value.ssh_proxy_jump
|
||
}
|
||
await api.createProject(payload)
|
||
showAdd.value = false
|
||
form.value = { id: '', name: '', path: '', tech_stack: '', priority: 5, project_type: 'development', ssh_host: '', ssh_user: 'root', ssh_key_path: '', ssh_proxy_jump: '' }
|
||
await load()
|
||
} catch (e: any) {
|
||
formError.value = e.message
|
||
}
|
||
}
|
||
|
||
async function runBootstrap() {
|
||
bsError.value = ''
|
||
bsResult.value = ''
|
||
try {
|
||
const res = await api.bootstrap(bsForm.value)
|
||
bsResult.value = `Created: ${res.project.id} (${res.project.name})`
|
||
await load()
|
||
} catch (e: any) {
|
||
bsError.value = e.message
|
||
}
|
||
}
|
||
|
||
function toggleNpRole(key: string) {
|
||
const idx = npRoles.value.indexOf(key)
|
||
if (idx >= 0) npRoles.value.splice(idx, 1)
|
||
else npRoles.value.push(key)
|
||
}
|
||
|
||
// Delete project
|
||
const confirmDeleteId = ref<string | null>(null)
|
||
const deleteError = ref('')
|
||
|
||
async function deleteProject(id: string) {
|
||
deleteError.value = ''
|
||
try {
|
||
await api.deleteProject(id)
|
||
projects.value = projects.value.filter(p => p.id !== id)
|
||
confirmDeleteId.value = null
|
||
} catch (e: any) {
|
||
deleteError.value = e.message
|
||
}
|
||
}
|
||
|
||
async function createNewProject() {
|
||
npError.value = ''
|
||
if (!npRoles.value.length) {
|
||
npError.value = 'Выберите хотя бы одну роль'
|
||
return
|
||
}
|
||
npSaving.value = true
|
||
try {
|
||
const ts = npForm.value.tech_stack ? npForm.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||
await api.newProject({
|
||
id: npForm.value.id,
|
||
name: npForm.value.name,
|
||
path: npForm.value.path,
|
||
description: npForm.value.description,
|
||
roles: npRoles.value,
|
||
tech_stack: ts,
|
||
priority: npForm.value.priority,
|
||
language: npForm.value.language,
|
||
})
|
||
showNewProject.value = false
|
||
npForm.value = { id: '', name: '', path: '', description: '', tech_stack: '', priority: 5, language: 'ru' }
|
||
npRoles.value = ['business_analyst', 'market_researcher', 'tech_researcher']
|
||
await load()
|
||
} catch (e: any) {
|
||
npError.value = e.message
|
||
} finally {
|
||
npSaving.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<div class="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
|
||
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button @click="showBootstrap = true"
|
||
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
|
||
Bootstrap
|
||
</button>
|
||
<button @click="showNewProject = true"
|
||
class="px-3 py-1.5 text-xs bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||
+ New Project
|
||
</button>
|
||
<button @click="showAdd = true"
|
||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||
+ Blank
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="loading" class="text-gray-500 text-sm">Loading...</p>
|
||
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
|
||
|
||
<div v-else class="grid gap-3">
|
||
<div v-for="p in projects" :key="p.id">
|
||
<!-- Inline delete confirmation -->
|
||
<div v-if="confirmDeleteId === p.id"
|
||
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
|
||
<p class="text-sm text-gray-200 mb-3">Удалить проект «{{ p.name }}»? Это действие необратимо.</p>
|
||
<div class="flex gap-2">
|
||
<button @click="deleteProject(p.id)"
|
||
title="Подтвердить удаление"
|
||
class="px-3 py-1.5 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||
Да, удалить
|
||
</button>
|
||
<button @click="confirmDeleteId = null"
|
||
title="Отмена удаления"
|
||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700">
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
|
||
</div>
|
||
<!-- Normal project card -->
|
||
<router-link v-else
|
||
:to="`/project/${p.id}`"
|
||
class="block border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors no-underline"
|
||
>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-sm font-semibold text-gray-200">{{ p.id }}</span>
|
||
<Badge :text="p.status" :color="statusColor(p.status)" />
|
||
<Badge v-if="p.project_type && p.project_type !== 'development'"
|
||
:text="p.project_type"
|
||
:color="p.project_type === 'operations' ? 'orange' : 'green'" />
|
||
<span class="text-sm text-gray-400">{{ p.name }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-3 text-xs text-gray-500">
|
||
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
|
||
<span>pri {{ p.priority }}</span>
|
||
<button @click.prevent.stop="confirmDeleteId = p.id"
|
||
title="Удалить проект"
|
||
class="text-gray-600 hover:text-red-400 transition-colors">
|
||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-4 text-xs">
|
||
<span class="text-gray-500">{{ p.total_tasks }} tasks</span>
|
||
<span v-if="p.active_tasks" class="text-blue-400">
|
||
<span class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse mr-0.5"></span>
|
||
{{ p.active_tasks }} active
|
||
</span>
|
||
<span v-if="p.review_tasks" class="text-yellow-400">{{ p.review_tasks }} awaiting review</span>
|
||
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
|
||
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
|
||
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) > 0" class="text-gray-500">
|
||
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
|
||
</span>
|
||
</div>
|
||
</router-link>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Add Project Modal -->
|
||
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false">
|
||
<form @submit.prevent="addProject" class="space-y-3">
|
||
<input v-model="form.id" placeholder="ID (e.g. vdol)" required
|
||
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="form.name" placeholder="Name" required
|
||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<!-- Project type selector -->
|
||
<div>
|
||
<p class="text-xs text-gray-500 mb-1.5">Тип проекта:</p>
|
||
<div class="flex gap-2">
|
||
<button v-for="t in ['development', 'operations', 'research']" :key="t"
|
||
type="button"
|
||
@click="form.project_type = t"
|
||
class="flex-1 py-1.5 text-xs border rounded transition-colors"
|
||
:class="form.project_type === t
|
||
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
|
||
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
|
||
: 'bg-green-900/40 text-green-300 border-green-700'
|
||
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
|
||
>{{ t }}</button>
|
||
</div>
|
||
</div>
|
||
<!-- Path (development / research) -->
|
||
<input v-if="form.project_type !== 'operations'"
|
||
v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)"
|
||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<!-- SSH fields (operations) -->
|
||
<template v-if="form.project_type === 'operations'">
|
||
<input v-model="form.ssh_host" placeholder="SSH host (e.g. 192.168.1.1)" required
|
||
class="w-full bg-gray-800 border border-orange-800/60 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<input v-model="form.ssh_user" placeholder="SSH user (e.g. root)"
|
||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)"
|
||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
</div>
|
||
<div>
|
||
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)"
|
||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<p class="mt-1 flex items-center gap-1 text-xs text-gray-500">
|
||
<svg class="w-3 h-3 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||
</svg>
|
||
Алиас из ~/.ssh/config на сервере Kin
|
||
</p>
|
||
</div>
|
||
</template>
|
||
<input v-model="form.tech_stack" placeholder="Tech stack (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" />
|
||
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
|
||
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="formError" class="text-red-400 text-xs">{{ formError }}</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>
|
||
|
||
<!-- New Project with Research Modal -->
|
||
<Modal v-if="showNewProject" title="New Project — Start Research" @close="showNewProject = false">
|
||
<form @submit.prevent="createNewProject" class="space-y-3">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<input v-model="npForm.id" placeholder="ID (e.g. myapp)" required
|
||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<input v-model="npForm.name" placeholder="Name" required
|
||
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
</div>
|
||
<input v-model="npForm.path" placeholder="Path (e.g. ~/projects/myapp)"
|
||
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="npForm.description" placeholder="Описание проекта (свободный текст для агентов)" required 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>
|
||
<input v-model="npForm.tech_stack" placeholder="Tech stack (comma-separated, optional)"
|
||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||
<div>
|
||
<p class="text-xs text-gray-500 mb-2">Этапы research (Architect добавляется автоматически последним):</p>
|
||
<div class="space-y-1.5">
|
||
<label v-for="r in RESEARCH_ROLES" :key="r.key"
|
||
class="flex items-start gap-2 cursor-pointer group">
|
||
<input type="checkbox"
|
||
:checked="npRoles.includes(r.key)"
|
||
@change="toggleNpRole(r.key)"
|
||
class="mt-0.5 accent-green-500 cursor-pointer" />
|
||
<div>
|
||
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</span>
|
||
<span class="text-xs text-gray-600 ml-1">— {{ r.hint }}</span>
|
||
</div>
|
||
</label>
|
||
<label class="flex items-start gap-2 opacity-50">
|
||
<input type="checkbox" checked disabled class="mt-0.5" />
|
||
<div>
|
||
<span class="text-sm text-gray-400">Architect</span>
|
||
<span class="text-xs text-gray-600 ml-1">— blueprint на основе одобренных исследований</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
|
||
<button type="submit" :disabled="npSaving"
|
||
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">
|
||
{{ npSaving ? 'Starting...' : 'Start Research' }}
|
||
</button>
|
||
</form>
|
||
</Modal>
|
||
|
||
<!-- Bootstrap Modal -->
|
||
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
|
||
<form @submit.prevent="runBootstrap" class="space-y-3">
|
||
<input v-model="bsForm.path" placeholder="Project path (e.g. ~/projects/vdolipoperek)" required
|
||
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="bsForm.id" placeholder="ID (e.g. vdol)" required
|
||
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="bsForm.name" placeholder="Name" required
|
||
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="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
|
||
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
|
||
<button type="submit"
|
||
class="w-full py-2 bg-purple-900/50 text-purple-400 border border-purple-800 rounded text-sm hover:bg-purple-900">
|
||
Bootstrap
|
||
</button>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
</template>
|