Add task detail view, pipeline visualization, approve/reject workflow
API (web/api.py) — 5 new endpoints:
GET /api/tasks/{id}/pipeline — agent_logs as pipeline steps
GET /api/tasks/{id}/full — task + steps + related decisions
POST /api/tasks/{id}/approve — mark done, optionally add decision
POST /api/tasks/{id}/reject — return to pending with reason
POST /api/tasks/{id}/run — launch pipeline in background (202)
Frontend:
TaskDetail (/task/:id) — full task page with:
- Pipeline graph: role cards with icons, arrows, status colors
- Click step → expand output (pre-formatted, JSON detected)
- Action bar: Approve (with optional decision), Reject, Run Pipeline
- Polling for live pipeline updates
Dashboard: review_tasks badge ("awaiting review" in yellow)
ProjectView: task rows are now clickable links to /task/:id
Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.
13 new API tests, 105 total, all passing. Frontend builds clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fabae74c19
commit
38c252fc1b
9 changed files with 550 additions and 7 deletions
107
web/api.py
107
web/api.py
|
|
@ -3,6 +3,7 @@ Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
|
|||
Run: uvicorn web.api:app --reload --port 8420
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import init_db
|
||||
|
|
@ -134,6 +136,111 @@ def create_task(body: TaskCreate):
|
|||
return t
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/pipeline")
|
||||
def get_task_pipeline(task_id: str):
|
||||
"""Get agent_logs for a task (pipeline steps)."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT id, agent_role, action, output_summary, success,
|
||||
duration_seconds, tokens_used, model, cost_usd, created_at
|
||||
FROM agent_logs WHERE task_id = ? ORDER BY created_at""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
steps = [dict(r) for r in rows]
|
||||
conn.close()
|
||||
return steps
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}/full")
|
||||
def get_task_full(task_id: str):
|
||||
"""Task + pipeline steps + related decisions."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
rows = conn.execute(
|
||||
"""SELECT id, agent_role, action, output_summary, success,
|
||||
duration_seconds, tokens_used, model, cost_usd, created_at
|
||||
FROM agent_logs WHERE task_id = ? ORDER BY created_at""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
steps = [dict(r) for r in rows]
|
||||
decisions = models.get_decisions(conn, t["project_id"])
|
||||
# Filter to decisions linked to this task
|
||||
task_decisions = [d for d in decisions if d.get("task_id") == task_id]
|
||||
conn.close()
|
||||
return {**t, "pipeline_steps": steps, "related_decisions": task_decisions}
|
||||
|
||||
|
||||
class TaskApprove(BaseModel):
|
||||
decision_title: str | None = None
|
||||
decision_description: str | None = None
|
||||
decision_type: str = "decision"
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/approve")
|
||||
def approve_task(task_id: str, body: TaskApprove | None = None):
|
||||
"""Approve a task: set status=done, optionally add a decision."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status="done")
|
||||
decision = None
|
||||
if body and body.decision_title:
|
||||
decision = models.add_decision(
|
||||
conn, t["project_id"], body.decision_type,
|
||||
body.decision_title, body.decision_description or body.decision_title,
|
||||
task_id=task_id,
|
||||
)
|
||||
conn.close()
|
||||
return {"status": "done", "decision": decision}
|
||||
|
||||
|
||||
class TaskReject(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reject")
|
||||
def reject_task(task_id: str, body: TaskReject):
|
||||
"""Reject a task: set status=pending with reason in review field."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
models.update_task(conn, task_id, status="pending", review={"rejected": body.reason})
|
||||
conn.close()
|
||||
return {"status": "pending", "reason": body.reason}
|
||||
|
||||
|
||||
@app.post("/api/tasks/{task_id}/run")
|
||||
def run_task(task_id: str):
|
||||
"""Launch pipeline for a task in background. Returns 202."""
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
if not t:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
conn.close()
|
||||
# Launch kin run in background subprocess
|
||||
kin_root = Path(__file__).parent.parent
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-m", "cli.main", "run", task_id, "--db",
|
||||
str(DB_PATH)],
|
||||
cwd=str(kin_root),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface Project {
|
|||
done_tasks: number
|
||||
active_tasks: number
|
||||
blocked_tasks: number
|
||||
review_tasks: number
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends Project {
|
||||
|
|
@ -73,6 +74,24 @@ export interface Module {
|
|||
dependencies: string[] | null
|
||||
}
|
||||
|
||||
export interface PipelineStep {
|
||||
id: number
|
||||
agent_role: string
|
||||
action: string
|
||||
output_summary: string | null
|
||||
success: boolean | number
|
||||
duration_seconds: number | null
|
||||
tokens_used: number | null
|
||||
model: string | null
|
||||
cost_usd: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskFull extends Task {
|
||||
pipeline_steps: PipelineStep[]
|
||||
related_decisions: Decision[]
|
||||
}
|
||||
|
||||
export interface CostEntry {
|
||||
project_id: string
|
||||
project_name: string
|
||||
|
|
@ -85,11 +104,20 @@ export interface CostEntry {
|
|||
export const api = {
|
||||
projects: () => get<Project[]>('/projects'),
|
||||
project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
|
||||
task: (id: string) => get<Task>(`/tasks/${id}`),
|
||||
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
|
||||
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
|
||||
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
||||
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
|
||||
post<Project>('/projects', data),
|
||||
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
|
||||
post<Task>('/tasks', data),
|
||||
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string }) =>
|
||||
post<{ status: string }>(`/tasks/${id}/approve`, data || {}),
|
||||
rejectTask: (id: string, reason: string) =>
|
||||
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
|
||||
runTask: (id: string) =>
|
||||
post<{ status: string }>(`/tasks/${id}/run`, {}),
|
||||
bootstrap: (data: { path: string; id: string; name: string }) =>
|
||||
post<{ project: Project }>('/bootstrap', data),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import './style.css'
|
|||
import App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import ProjectView from './views/ProjectView.vue'
|
||||
import TaskDetail from './views/TaskDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/project/:id', component: ProjectView, props: true },
|
||||
{ path: '/task/:id', component: TaskDetail, props: true },
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -116,10 +116,11 @@ async function runBootstrap() {
|
|||
<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.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 > 0" class="text-gray-500">
|
||||
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -190,8 +190,9 @@ async function addDecision() {
|
|||
</div>
|
||||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="t in filteredTasks" :key="t.id"
|
||||
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
:to="`/task/${t.id}`"
|
||||
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)" />
|
||||
|
|
@ -201,7 +202,7 @@ async function addDecision() {
|
|||
<span v-if="t.assigned_role">{{ t.assigned_role }}</span>
|
||||
<span>pri {{ t.priority }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
256
web/frontend/src/views/TaskDetail.vue
Normal file
256
web/frontend/src/views/TaskDetail.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { api, type TaskFull, type PipelineStep } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const selectedStep = ref<PipelineStep | null>(null)
|
||||
const polling = ref(false)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Approve modal
|
||||
const showApprove = ref(false)
|
||||
const approveForm = ref({ title: '', description: '', type: 'decision' })
|
||||
|
||||
// Reject modal
|
||||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
loading.value = true
|
||||
task.value = await api.taskFull(props.id)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function statusColor(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
pending: 'gray', in_progress: 'blue', review: 'yellow',
|
||||
done: 'green', blocked: 'red', decomposed: 'purple',
|
||||
}
|
||||
return m[s] || 'gray'
|
||||
}
|
||||
|
||||
const roleIcons: Record<string, string> = {
|
||||
pm: '\u{1F9E0}', security: '\u{1F6E1}', debugger: '\u{1F50D}',
|
||||
frontend_dev: '\u{1F4BB}', backend_dev: '\u{2699}', tester: '\u{2705}',
|
||||
reviewer: '\u{1F4CB}', architect: '\u{1F3D7}',
|
||||
}
|
||||
|
||||
function stepStatusClass(step: PipelineStep) {
|
||||
if (step.success) return 'border-green-700 bg-green-950/30'
|
||||
return 'border-red-700 bg-red-950/30'
|
||||
}
|
||||
|
||||
function stepStatusIcon(step: PipelineStep) {
|
||||
return step.success ? '\u2713' : '\u2717'
|
||||
}
|
||||
|
||||
function stepStatusColor(step: PipelineStep) {
|
||||
return step.success ? 'text-green-400' : 'text-red-400'
|
||||
}
|
||||
|
||||
function formatOutput(text: string | null): string {
|
||||
if (!text) return ''
|
||||
// Try to detect and format JSON
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
const data = approveForm.value.title
|
||||
? { decision_title: approveForm.value.title, decision_description: approveForm.value.description, decision_type: approveForm.value.type }
|
||||
: undefined
|
||||
await api.approveTask(props.id, data)
|
||||
showApprove.value = false
|
||||
approveForm.value = { title: '', description: '', type: 'decision' }
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!task.value || !rejectReason.value) return
|
||||
try {
|
||||
await api.rejectTask(props.id, rejectReason.value)
|
||||
showReject.value = false
|
||||
rejectReason.value = ''
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function runPipeline() {
|
||||
try {
|
||||
await api.runTask(props.id)
|
||||
polling.value = true
|
||||
pollTimer = setInterval(async () => {
|
||||
await load()
|
||||
if (task.value && !['in_progress'].includes(task.value.status)) {
|
||||
stopPolling()
|
||||
}
|
||||
}, 3000)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
polling.value = false
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
</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="task">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<router-link :to="`/project/${task.project_id}`" class="text-gray-600 hover:text-gray-400 text-sm no-underline">
|
||||
← {{ task.project_id }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
|
||||
<span class="text-gray-400">{{ task.title }}</span>
|
||||
<Badge :text="task.status" :color="statusColor(task.status)" />
|
||||
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
|
||||
</div>
|
||||
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
|
||||
Brief: {{ JSON.stringify(task.brief) }}
|
||||
</div>
|
||||
<div v-if="task.assigned_role" class="text-xs text-gray-500">
|
||||
Assigned: {{ task.assigned_role }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Graph -->
|
||||
<div v-if="hasSteps" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">Pipeline</h2>
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
||||
<!-- Arrow -->
|
||||
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">→</div>
|
||||
<!-- Step card -->
|
||||
<button
|
||||
@click="selectedStep = selectedStep?.id === step.id ? null : step"
|
||||
class="border rounded-lg px-3 py-2 min-w-[120px] text-left transition-all shrink-0"
|
||||
:class="[
|
||||
stepStatusClass(step),
|
||||
selectedStep?.id === step.id ? 'ring-1 ring-blue-500' : '',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 mb-1">
|
||||
<span class="text-base">{{ roleIcons[step.agent_role] || '\u{1F916}' }}</span>
|
||||
<span class="text-xs font-medium text-gray-300">{{ step.agent_role }}</span>
|
||||
<span :class="stepStatusColor(step)" class="text-xs ml-auto">{{ stepStatusIcon(step) }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 text-[10px] text-gray-500">
|
||||
<span v-if="step.duration_seconds">{{ step.duration_seconds }}s</span>
|
||||
<span v-if="step.tokens_used">{{ step.tokens_used?.toLocaleString() }}tk</span>
|
||||
<span v-if="step.cost_usd">${{ step.cost_usd?.toFixed(3) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No pipeline -->
|
||||
<div v-else class="mb-6 text-sm text-gray-600">
|
||||
No pipeline steps yet.
|
||||
</div>
|
||||
|
||||
<!-- Selected step output -->
|
||||
<div v-if="selectedStep" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">
|
||||
Output: {{ selectedStep.agent_role }}
|
||||
<span class="text-xs text-gray-600 font-normal ml-2">{{ selectedStep.created_at }}</span>
|
||||
</h2>
|
||||
<div class="border border-gray-800 rounded-lg bg-gray-900/50 overflow-hidden">
|
||||
<pre class="p-4 text-xs text-gray-300 overflow-x-auto whitespace-pre-wrap max-h-[600px] overflow-y-auto">{{ formatOutput(selectedStep.output_summary) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Decisions -->
|
||||
<div v-if="task.related_decisions?.length" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-2">Related Decisions</h2>
|
||||
<div class="space-y-1">
|
||||
<div v-for="d in task.related_decisions" :key="d.id"
|
||||
class="px-3 py-2 border border-gray-800 rounded text-xs">
|
||||
<Badge :text="d.type" :color="d.type === 'gotcha' ? 'red' : 'blue'" />
|
||||
<span class="text-gray-300 ml-2">{{ d.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
|
||||
<button v-if="task.status === 'review'"
|
||||
@click="showApprove = true"
|
||||
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
✓ Approve
|
||||
</button>
|
||||
<button v-if="task.status === 'review' || task.status === 'in_progress'"
|
||||
@click="showReject = true"
|
||||
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
✗ Reject
|
||||
</button>
|
||||
<button v-if="task.status === 'pending' || task.status === 'blocked'"
|
||||
@click="runPipeline"
|
||||
:disabled="polling"
|
||||
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ polling ? 'Running...' : '▶ Run Pipeline' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Approve Modal -->
|
||||
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false">
|
||||
<form @submit.prevent="approve" class="space-y-3">
|
||||
<p class="text-sm text-gray-400">Optionally record a decision from this task:</p>
|
||||
<input v-model="approveForm.title" placeholder="Decision title (optional)"
|
||||
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-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
|
||||
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" rows="2"></textarea>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900">
|
||||
Approve & mark done
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<Modal v-if="showReject" title="Reject Task" @close="showReject = false">
|
||||
<form @submit.prevent="reject" class="space-y-3">
|
||||
<textarea v-model="rejectReason" placeholder="Why are you rejecting this?" 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>
|
||||
<button type="submit"
|
||||
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900">
|
||||
Reject & return to pending
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue