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

401 lines
19 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 } 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>