kin: KIN-UI-002 Исправить падающие тесты миграции (регрессия KIN-ARCH-003) в core/db.py
This commit is contained in:
parent
389b266bee
commit
ff69d24acc
7 changed files with 254 additions and 10 deletions
|
|
@ -7,6 +7,7 @@ Your job: review the implementation for correctness, security, and adherence to
|
||||||
You receive:
|
You receive:
|
||||||
- PROJECT: id, name, path, tech stack
|
- PROJECT: id, name, path, tech stack
|
||||||
- TASK: id, title, brief describing what was built
|
- TASK: id, title, brief describing what was built
|
||||||
|
- ACCEPTANCE CRITERIA: what the task output must satisfy (if provided — verify the implementation meets each criterion before approving)
|
||||||
- DECISIONS: project conventions and standards
|
- DECISIONS: project conventions and standards
|
||||||
- PREVIOUS STEP OUTPUT: dev agent and/or tester output describing what was changed
|
- PREVIOUS STEP OUTPUT: dev agent and/or tester output describing what was changed
|
||||||
|
|
||||||
|
|
@ -35,6 +36,7 @@ You receive:
|
||||||
- Check that API endpoints validate input and return proper HTTP status codes.
|
- Check that API endpoints validate input and return proper HTTP status codes.
|
||||||
- Check that no secrets, tokens, or credentials are hardcoded.
|
- Check that no secrets, tokens, or credentials are hardcoded.
|
||||||
- Do NOT rewrite code — only report findings and recommendations.
|
- Do NOT rewrite code — only report findings and recommendations.
|
||||||
|
- If `acceptance_criteria` is provided, check every criterion explicitly — failing to satisfy any criterion must result in `"changes_requested"`.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ For a specific test file: `python -m pytest tests/test_models.py -v`
|
||||||
- One test per behavior — don't combine multiple assertions in one test without clear reason.
|
- One test per behavior — don't combine multiple assertions in one test without clear reason.
|
||||||
- Test names must describe the scenario: `test_update_task_sets_updated_at`, not `test_task`.
|
- Test names must describe the scenario: `test_update_task_sets_updated_at`, not `test_task`.
|
||||||
- Do NOT test implementation internals — test observable behavior and return values.
|
- Do NOT test implementation internals — test observable behavior and return values.
|
||||||
|
- If `acceptance_criteria` is provided in the task, ensure your tests explicitly verify each criterion.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
|
|
|
||||||
28
core/db.py
28
core/db.py
|
|
@ -371,6 +371,34 @@ def _migrate(conn: sqlite3.Connection):
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Migrate columns that must exist before table recreation (KIN-UI-002)
|
||||||
|
# These columns are referenced in the INSERT SELECT below but were not added
|
||||||
|
# by any prior ALTER TABLE in this chain — causing OperationalError on minimal schemas.
|
||||||
|
if "tech_stack" not in proj_cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN tech_stack JSON DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "priority" not in proj_cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN priority INTEGER DEFAULT 5")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "pm_prompt" not in proj_cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN pm_prompt TEXT DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "claude_md_path" not in proj_cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN claude_md_path TEXT DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "forgejo_repo" not in proj_cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN forgejo_repo TEXT DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if "created_at" not in proj_cols:
|
||||||
|
# SQLite ALTER TABLE does not allow non-constant defaults like CURRENT_TIMESTAMP
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN created_at DATETIME DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# Migrate projects.path from NOT NULL to nullable (KIN-ARCH-003)
|
# Migrate projects.path from NOT NULL to nullable (KIN-ARCH-003)
|
||||||
# SQLite doesn't support ALTER COLUMN, so we recreate the table.
|
# SQLite doesn't support ALTER COLUMN, so we recreate the table.
|
||||||
path_col_rows = conn.execute("PRAGMA table_info(projects)").fetchall()
|
path_col_rows = conn.execute("PRAGMA table_info(projects)").fetchall()
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,73 @@ def test_migrate_is_idempotent():
|
||||||
after = _cols(conn, "projects")
|
after = _cols(conn, "projects")
|
||||||
assert before == after
|
assert before == after
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Migration KIN-UI-002: рекреация таблицы на минимальной схеме не падает
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_migrate_recreates_table_without_operationalerror():
|
||||||
|
"""_migrate не бросает OperationalError при рекреации projects на минимальной схеме.
|
||||||
|
|
||||||
|
Регрессионный тест KIN-UI-002: INSERT SELECT в блоке KIN-ARCH-003 ранее
|
||||||
|
падал на отсутствующих колонках (tech_stack, priority, pm_prompt и др.).
|
||||||
|
"""
|
||||||
|
conn = _old_schema_conn() # path NOT NULL — триггер рекреации
|
||||||
|
try:
|
||||||
|
_migrate(conn)
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.fail(f"_migrate raised {type(exc).__name__}: {exc}")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_path_becomes_nullable_on_old_schema():
|
||||||
|
"""После миграции старой схемы (path NOT NULL) колонка path становится nullable."""
|
||||||
|
conn = _old_schema_conn()
|
||||||
|
_migrate(conn)
|
||||||
|
path_col = next(
|
||||||
|
r for r in conn.execute("PRAGMA table_info(projects)").fetchall()
|
||||||
|
if r[1] == "path"
|
||||||
|
)
|
||||||
|
assert path_col[3] == 0, "path должна быть nullable после миграции KIN-ARCH-003"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_preserves_existing_rows_on_recreation():
|
||||||
|
"""Рекреация таблицы сохраняет существующие строки."""
|
||||||
|
conn = _old_schema_conn()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, name, path, status) VALUES ('p1', 'MyProj', '/p', 'active')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
_migrate(conn)
|
||||||
|
row = conn.execute("SELECT id, name, path, status FROM projects WHERE id='p1'").fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["name"] == "MyProj"
|
||||||
|
assert row["path"] == "/p"
|
||||||
|
assert row["status"] == "active"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_adds_missing_columns_before_recreation():
|
||||||
|
"""_migrate добавляет tech_stack, priority, pm_prompt, claude_md_path, forgejo_repo, created_at перед рекреацией."""
|
||||||
|
conn = _old_schema_conn()
|
||||||
|
_migrate(conn)
|
||||||
|
cols = _cols(conn, "projects")
|
||||||
|
required = {"tech_stack", "priority", "pm_prompt", "claude_md_path", "forgejo_repo", "created_at"}
|
||||||
|
assert required.issubset(cols), f"Отсутствуют колонки: {required - cols}"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_operations_project_with_null_path():
|
||||||
|
"""После миграции можно вставить operations-проект с path=NULL."""
|
||||||
|
conn = _old_schema_conn()
|
||||||
|
_migrate(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, name, path, project_type) VALUES ('ops1', 'Ops', NULL, 'operations')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute("SELECT path, project_type FROM projects WHERE id='ops1'").fetchone()
|
||||||
|
assert row["path"] is None
|
||||||
|
assert row["project_type"] == "operations"
|
||||||
|
conn.close()
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export interface Task {
|
||||||
blocked_reason: string | null
|
blocked_reason: string | null
|
||||||
dangerously_skipped: number | null
|
dangerously_skipped: number | null
|
||||||
category: string | null
|
category: string | null
|
||||||
|
acceptance_criteria: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +224,7 @@ export const api = {
|
||||||
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
||||||
createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
|
createProject: (data: { id: string; name: string; path?: string; tech_stack?: string[]; priority?: number; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
|
||||||
post<Project>('/projects', data),
|
post<Project>('/projects', data),
|
||||||
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string }) =>
|
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string; acceptance_criteria?: string }) =>
|
||||||
post<Task>('/tasks', data),
|
post<Task>('/tasks', data),
|
||||||
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
|
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
|
||||||
post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}),
|
post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}),
|
||||||
|
|
@ -241,7 +242,7 @@ export const api = {
|
||||||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||||
auditApply: (projectId: string, taskIds: string[]) =>
|
auditApply: (projectId: string, taskIds: string[]) =>
|
||||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||||
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
|
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) =>
|
||||||
patch<Task>(`/tasks/${id}`, data),
|
patch<Task>(`/tasks/${id}`, data),
|
||||||
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
|
patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string }) =>
|
||||||
patch<Project>(`/projects/${id}`, data),
|
patch<Project>(`/projects/${id}`, data),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
|
import { api, type ProjectDetail, type AuditResult, type Phase, type Task } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ const router = useRouter()
|
||||||
const project = ref<ProjectDetail | null>(null)
|
const project = ref<ProjectDetail | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules'>('tasks')
|
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban'>('tasks')
|
||||||
|
|
||||||
// Phases
|
// Phases
|
||||||
const phases = ref<Phase[]>([])
|
const phases = ref<Phase[]>([])
|
||||||
|
|
@ -247,7 +247,7 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||||
FIX: 'rose', OBS: 'teal',
|
FIX: 'rose', OBS: 'teal',
|
||||||
}
|
}
|
||||||
const showAddTask = ref(false)
|
const showAddTask = ref(false)
|
||||||
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' })
|
const taskForm = ref({ title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' })
|
||||||
const taskFormError = ref('')
|
const taskFormError = ref('')
|
||||||
|
|
||||||
// Add decision modal
|
// Add decision modal
|
||||||
|
|
@ -280,6 +280,7 @@ onMounted(async () => {
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
|
if (phasePollTimer) { clearInterval(phasePollTimer); phasePollTimer = null }
|
||||||
|
if (kanbanPollTimer) { clearInterval(kanbanPollTimer); kanbanPollTimer = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskCategories = computed(() => {
|
const taskCategories = computed(() => {
|
||||||
|
|
@ -352,9 +353,10 @@ async function addTask() {
|
||||||
priority: taskForm.value.priority,
|
priority: taskForm.value.priority,
|
||||||
route_type: taskForm.value.route_type || undefined,
|
route_type: taskForm.value.route_type || undefined,
|
||||||
category: taskForm.value.category || undefined,
|
category: taskForm.value.category || undefined,
|
||||||
|
acceptance_criteria: taskForm.value.acceptance_criteria || undefined,
|
||||||
})
|
})
|
||||||
showAddTask.value = false
|
showAddTask.value = false
|
||||||
taskForm.value = { title: '', priority: 5, route_type: '', category: '' }
|
taskForm.value = { title: '', priority: 5, route_type: '', category: '', acceptance_criteria: '' }
|
||||||
await load()
|
await load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
taskFormError.value = e.message
|
taskFormError.value = e.message
|
||||||
|
|
@ -385,6 +387,87 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kanban
|
||||||
|
const KANBAN_COLUMNS = [
|
||||||
|
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
|
||||||
|
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
|
||||||
|
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
|
||||||
|
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
|
||||||
|
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const draggingTaskId = ref<string | null>(null)
|
||||||
|
const dragOverStatus = ref<string | null>(null)
|
||||||
|
let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const kanbanTasksByStatus = computed(() => {
|
||||||
|
if (!project.value) return {} as Record<string, Task[]>
|
||||||
|
const result: Record<string, Task[]> = {}
|
||||||
|
for (const col of KANBAN_COLUMNS) result[col.status] = []
|
||||||
|
for (const t of project.value.tasks) {
|
||||||
|
if (result[t.status]) result[t.status].push(t)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkAndPollKanban() {
|
||||||
|
const hasRunning = project.value?.tasks.some(t => t.status === 'in_progress') ?? false
|
||||||
|
if (hasRunning && !kanbanPollTimer) {
|
||||||
|
kanbanPollTimer = setInterval(async () => {
|
||||||
|
project.value = await api.project(props.id).catch(() => project.value)
|
||||||
|
if (!project.value?.tasks.some(t => t.status === 'in_progress')) {
|
||||||
|
clearInterval(kanbanPollTimer!)
|
||||||
|
kanbanPollTimer = null
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
} else if (!hasRunning && kanbanPollTimer) {
|
||||||
|
clearInterval(kanbanPollTimer)
|
||||||
|
kanbanPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, (tab) => {
|
||||||
|
if (tab === 'kanban') {
|
||||||
|
checkAndPollKanban()
|
||||||
|
} else if (kanbanPollTimer) {
|
||||||
|
clearInterval(kanbanPollTimer)
|
||||||
|
kanbanPollTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onDragStart(event: DragEvent, taskId: string) {
|
||||||
|
draggingTaskId.value = taskId
|
||||||
|
event.dataTransfer?.setData('text/plain', taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
draggingTaskId.value = null
|
||||||
|
dragOverStatus.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(event: DragEvent) {
|
||||||
|
const target = event.currentTarget as HTMLElement
|
||||||
|
const related = event.relatedTarget as HTMLElement | null
|
||||||
|
if (!related || !target.contains(related)) dragOverStatus.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onKanbanDrop(event: DragEvent, newStatus: string) {
|
||||||
|
dragOverStatus.value = null
|
||||||
|
const taskId = event.dataTransfer?.getData('text/plain') || draggingTaskId.value
|
||||||
|
draggingTaskId.value = null
|
||||||
|
if (!taskId || !project.value) return
|
||||||
|
const task = project.value.tasks.find(t => t.id === taskId)
|
||||||
|
if (!task || task.status === newStatus) return
|
||||||
|
try {
|
||||||
|
const updated = await api.patchTask(taskId, { status: newStatus })
|
||||||
|
const idx = project.value.tasks.findIndex(t => t.id === taskId)
|
||||||
|
if (idx >= 0) project.value.tasks[idx] = updated
|
||||||
|
checkAndPollKanban()
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addDecision() {
|
async function addDecision() {
|
||||||
decFormError.value = ''
|
decFormError.value = ''
|
||||||
try {
|
try {
|
||||||
|
|
@ -441,18 +524,19 @@ async function addDecision() {
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
<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"
|
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules', 'kanban'] as const)" :key="tab"
|
||||||
@click="activeTab = tab"
|
@click="activeTab = tab"
|
||||||
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
||||||
:class="activeTab === tab
|
:class="activeTab === tab
|
||||||
? 'text-gray-200 border-blue-500'
|
? 'text-gray-200 border-blue-500'
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
: 'text-gray-500 border-transparent hover:text-gray-300'">
|
||||||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
{{ tab === 'kanban' ? 'Kanban' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||||
<span class="text-xs text-gray-600 ml-1">
|
<span class="text-xs text-gray-600 ml-1">
|
||||||
{{ tab === 'tasks' ? project.tasks.length
|
{{ tab === 'tasks' ? project.tasks.length
|
||||||
: tab === 'phases' ? phases.length
|
: tab === 'phases' ? phases.length
|
||||||
: tab === 'decisions' ? project.decisions.length
|
: tab === 'decisions' ? project.decisions.length
|
||||||
: project.modules.length }}
|
: tab === 'modules' ? project.modules.length
|
||||||
|
: project.tasks.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -739,6 +823,45 @@ async function addDecision() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kanban Tab -->
|
||||||
|
<div v-if="activeTab === 'kanban'" class="overflow-x-auto pb-4">
|
||||||
|
<div class="flex gap-3" style="min-width: max-content">
|
||||||
|
<div v-for="col in KANBAN_COLUMNS" :key="col.status" class="w-64 flex flex-col gap-2">
|
||||||
|
<!-- Column header -->
|
||||||
|
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide" :class="col.headerClass">{{ col.label }}</span>
|
||||||
|
<span class="text-xs text-gray-600">({{ kanbanTasksByStatus[col.status]?.length || 0 }})</span>
|
||||||
|
</div>
|
||||||
|
<!-- Drop zone -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-1.5 min-h-24 rounded p-1.5 border transition-colors"
|
||||||
|
:class="[col.bgClass, dragOverStatus === col.status ? 'border-blue-600' : 'border-transparent']"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragenter.prevent="dragOverStatus = col.status"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onKanbanDrop($event, col.status)">
|
||||||
|
<router-link
|
||||||
|
v-for="t in kanbanTasksByStatus[col.status]" :key="t.id"
|
||||||
|
:to="`/task/${t.id}`"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, t.id)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
class="block px-2.5 py-2 bg-gray-900 border border-gray-800 rounded text-xs hover:border-gray-600 no-underline cursor-grab active:cursor-grabbing transition-colors select-none"
|
||||||
|
:class="draggingTaskId === t.id ? 'opacity-40' : ''">
|
||||||
|
<div class="text-gray-500 mb-1 text-[10px]">{{ t.id }}</div>
|
||||||
|
<div class="text-gray-300 leading-snug mb-1.5">{{ t.title }}</div>
|
||||||
|
<div class="flex items-center gap-1 flex-wrap">
|
||||||
|
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
|
||||||
|
<span class="text-gray-600 text-[10px] ml-auto">p{{ t.priority }}</span>
|
||||||
|
<span v-if="t.status === 'in_progress'" class="inline-block w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<div v-if="!kanbanTasksByStatus[col.status]?.length" class="text-xs text-gray-700 text-center py-6">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add Task Modal -->
|
<!-- Add Task Modal -->
|
||||||
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
<Modal v-if="showAddTask" title="Add Task" @close="showAddTask = false">
|
||||||
<form @submit.prevent="addTask" class="space-y-3">
|
<form @submit.prevent="addTask" class="space-y-3">
|
||||||
|
|
@ -759,6 +882,12 @@ async function addDecision() {
|
||||||
</select>
|
</select>
|
||||||
<input v-model.number="taskForm.priority" type="number" min="1" max="10" placeholder="Priority"
|
<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" />
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
|
||||||
|
<textarea v-model="taskForm.acceptance_criteria" rows="3"
|
||||||
|
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
<p v-if="taskFormError" class="text-red-400 text-xs">{{ taskFormError }}</p>
|
||||||
<button type="submit"
|
<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">
|
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ async function runDeploy() {
|
||||||
|
|
||||||
// Edit modal (pending tasks only)
|
// Edit modal (pending tasks only)
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
|
||||||
const editLoading = ref(false)
|
const editLoading = ref(false)
|
||||||
const editError = ref('')
|
const editError = ref('')
|
||||||
|
|
||||||
|
|
@ -298,6 +298,7 @@ function openEdit() {
|
||||||
title: task.value.title,
|
title: task.value.title,
|
||||||
briefText: getBriefText(task.value.brief),
|
briefText: getBriefText(task.value.brief),
|
||||||
priority: task.value.priority,
|
priority: task.value.priority,
|
||||||
|
acceptanceCriteria: task.value.acceptance_criteria ?? '',
|
||||||
}
|
}
|
||||||
editError.value = ''
|
editError.value = ''
|
||||||
showEdit.value = true
|
showEdit.value = true
|
||||||
|
|
@ -313,6 +314,8 @@ async function saveEdit() {
|
||||||
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
|
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
|
||||||
const origBriefText = getBriefText(task.value.brief)
|
const origBriefText = getBriefText(task.value.brief)
|
||||||
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
|
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
|
||||||
|
const origAC = task.value.acceptance_criteria ?? ''
|
||||||
|
if (editForm.value.acceptanceCriteria !== origAC) data.acceptance_criteria = editForm.value.acceptanceCriteria
|
||||||
if (Object.keys(data).length === 0) { showEdit.value = false; return }
|
if (Object.keys(data).length === 0) { showEdit.value = false; return }
|
||||||
const updated = await api.patchTask(props.id, data)
|
const updated = await api.patchTask(props.id, data)
|
||||||
task.value = { ...task.value, ...updated }
|
task.value = { ...task.value, ...updated }
|
||||||
|
|
@ -387,6 +390,10 @@ async function saveEdit() {
|
||||||
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
|
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
|
||||||
Brief: {{ JSON.stringify(task.brief) }}
|
Brief: {{ JSON.stringify(task.brief) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
|
||||||
|
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
|
||||||
|
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
|
||||||
|
</div>
|
||||||
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
|
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
|
||||||
Blocked: {{ task.blocked_reason }}
|
Blocked: {{ task.blocked_reason }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -628,6 +635,12 @@ async function saveEdit() {
|
||||||
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
|
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
|
||||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
|
||||||
|
<textarea v-model="editForm.acceptanceCriteria" rows="3"
|
||||||
|
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
|
||||||
<button type="submit" :disabled="editLoading"
|
<button type="submit" :disabled="editLoading"
|
||||||
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue