168 lines
6.9 KiB
Vue
168 lines
6.9 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 })
|
||
|
|
const formError = ref('')
|
||
|
|
|
||
|
|
// Bootstrap modal
|
||
|
|
const showBootstrap = ref(false)
|
||
|
|
const bsForm = ref({ id: '', name: '', path: '' })
|
||
|
|
const bsError = ref('')
|
||
|
|
const bsResult = ref('')
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(load)
|
||
|
|
|
||
|
|
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 = ''
|
||
|
|
try {
|
||
|
|
const ts = form.value.tech_stack ? form.value.tech_stack.split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||
|
|
await api.createProject({ ...form.value, tech_stack: ts, priority: form.value.priority })
|
||
|
|
showAdd.value = false
|
||
|
|
form.value = { id: '', name: '', path: '', tech_stack: '', priority: 5 }
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</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="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">
|
||
|
|
+ Project
|
||
|
|
</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">
|
||
|
|
<router-link
|
||
|
|
v-for="p in projects" :key="p.id"
|
||
|
|
: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)" />
|
||
|
|
<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>
|
||
|
|
</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">{{ p.active_tasks }} active</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 > 0" class="text-gray-500">
|
||
|
|
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</router-link>
|
||
|
|
</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" />
|
||
|
|
<input v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)" 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.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>
|
||
|
|
|
||
|
|
<!-- 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>
|