Compare commits
3 commits
4f50c4eb73
...
8623323161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8623323161 | ||
|
|
1487e84eb6 | ||
|
|
e33a89c82c |
8 changed files with 430 additions and 3 deletions
|
|
@ -773,6 +773,12 @@ def _migrate(conn: sqlite3.Connection):
|
||||||
PRAGMA foreign_keys=ON;
|
PRAGMA foreign_keys=ON;
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# KIN-126: Add completed_at to tasks — set when task transitions to 'done'
|
||||||
|
task_cols_final = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
if "completed_at" not in task_cols_final:
|
||||||
|
conn.execute("ALTER TABLE tasks ADD COLUMN completed_at DATETIME DEFAULT NULL")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _seed_default_hooks(conn: sqlite3.Connection):
|
def _seed_default_hooks(conn: sqlite3.Connection):
|
||||||
"""Seed default hooks for the kin project (idempotent).
|
"""Seed default hooks for the kin project (idempotent).
|
||||||
|
|
|
||||||
|
|
@ -304,13 +304,15 @@ def list_tasks(
|
||||||
|
|
||||||
|
|
||||||
def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
|
def update_task(conn: sqlite3.Connection, id: str, **fields) -> dict:
|
||||||
"""Update task fields. Auto-sets updated_at."""
|
"""Update task fields. Auto-sets updated_at. Sets completed_at when status transitions to 'done'."""
|
||||||
if not fields:
|
if not fields:
|
||||||
return get_task(conn, id)
|
return get_task(conn, id)
|
||||||
json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels")
|
json_cols = ("brief", "spec", "review", "test_result", "security_result", "labels")
|
||||||
for key in json_cols:
|
for key in json_cols:
|
||||||
if key in fields:
|
if key in fields:
|
||||||
fields[key] = _json_encode(fields[key])
|
fields[key] = _json_encode(fields[key])
|
||||||
|
if "status" in fields and fields["status"] == "done":
|
||||||
|
fields["completed_at"] = datetime.now().isoformat()
|
||||||
fields["updated_at"] = datetime.now().isoformat()
|
fields["updated_at"] = datetime.now().isoformat()
|
||||||
sets = ", ".join(f"{k} = ?" for k in fields)
|
sets = ", ".join(f"{k} = ?" for k in fields)
|
||||||
vals = list(fields.values()) + [id]
|
vals = list(fields.values()) + [id]
|
||||||
|
|
|
||||||
108
tests/test_kin_126_regression.py
Normal file
108
tests/test_kin_126_regression.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""Regression tests for KIN-126 — фильтр по дате выполнения задач.
|
||||||
|
|
||||||
|
Проверяет backend-поведение:
|
||||||
|
1. Колонка completed_at добавляется в таблицу tasks при инициализации БД
|
||||||
|
2. Новая задача имеет completed_at=None по умолчанию
|
||||||
|
3. update_task(status='done') устанавливает completed_at как ISO-строку
|
||||||
|
4. update_task(status='done') — completed_at является валидной ISO-строкой
|
||||||
|
5. update_task(status='in_progress') не устанавливает completed_at
|
||||||
|
6. update_task(status='cancelled') не устанавливает completed_at
|
||||||
|
7. Обновление полей без смены статуса на done не трогает completed_at
|
||||||
|
8. update_task(status='blocked') не устанавливает completed_at
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
"""Fresh in-memory DB for each test."""
|
||||||
|
c = init_db(db_path=":memory:")
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_schema_tasks_has_completed_at_column(conn):
|
||||||
|
"""KIN-126: таблица tasks содержит колонку completed_at после инициализации БД."""
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||||
|
assert "completed_at" in cols, "KIN-126: колонка completed_at должна быть в таблице tasks"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default value
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_new_task_completed_at_is_null(conn):
|
||||||
|
"""KIN-126: новая задача имеет completed_at=None по умолчанию."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
t = models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
assert t["completed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Setting completed_at on status='done'
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_update_task_to_done_sets_completed_at(conn):
|
||||||
|
"""KIN-126: update_task(status='done') устанавливает completed_at."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", status="done")
|
||||||
|
assert updated["completed_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_task_to_done_completed_at_is_valid_iso(conn):
|
||||||
|
"""KIN-126: completed_at при status='done' — валидная ISO-строка datetime."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", status="done")
|
||||||
|
# Must parse without exception
|
||||||
|
parsed = datetime.fromisoformat(updated["completed_at"])
|
||||||
|
assert parsed is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-done statuses do NOT set completed_at
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_update_task_to_in_progress_does_not_set_completed_at(conn):
|
||||||
|
"""KIN-126: update_task(status='in_progress') не устанавливает completed_at."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", status="in_progress")
|
||||||
|
assert updated["completed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_task_to_cancelled_does_not_set_completed_at(conn):
|
||||||
|
"""KIN-126: update_task(status='cancelled') не устанавливает completed_at."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", status="cancelled")
|
||||||
|
assert updated["completed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_task_to_blocked_does_not_set_completed_at(conn):
|
||||||
|
"""KIN-126: update_task(status='blocked') не устанавливает completed_at."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", status="blocked")
|
||||||
|
assert updated["completed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-status updates do NOT touch completed_at
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_update_task_priority_only_does_not_set_completed_at(conn):
|
||||||
|
"""KIN-126: update_task с изменением только priority не трогает completed_at."""
|
||||||
|
models.create_project(conn, "p1", "P1", "/p1")
|
||||||
|
models.create_task(conn, "P1-001", "p1", "Task")
|
||||||
|
updated = models.update_task(conn, "P1-001", priority=1)
|
||||||
|
assert updated["completed_at"] is None
|
||||||
283
web/frontend/src/__tests__/date-filter.test.ts
Normal file
283
web/frontend/src/__tests__/date-filter.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
/**
|
||||||
|
* KIN-126: Тесты фильтра по дате выполнения задач (done tasks date range filter)
|
||||||
|
*
|
||||||
|
* AC: фильтр сделан и работает
|
||||||
|
*
|
||||||
|
* Проверяет:
|
||||||
|
* 1. Date filter inputs отображаются только когда выбран статус 'done'
|
||||||
|
* 2. Date filter inputs скрыты на других статусах
|
||||||
|
* 3. Date filter inputs скрыты без фильтра статуса
|
||||||
|
* 4. Фильтрация по dateFrom — задачи до этой даты исключаются
|
||||||
|
* 5. Фильтрация по dateTo — задачи после этой даты исключаются
|
||||||
|
* 6. Пустой результат при несовпадении дат
|
||||||
|
* 7. Сброс фильтра (кнопка ✕) показывает все done-задачи снова
|
||||||
|
* 8. Fallback на updated_at когда completed_at=null
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import ProjectView from '../views/ProjectView.vue'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
api: {
|
||||||
|
project: vi.fn(),
|
||||||
|
taskFull: vi.fn(),
|
||||||
|
runTask: vi.fn(),
|
||||||
|
auditProject: vi.fn(),
|
||||||
|
createTask: vi.fn(),
|
||||||
|
patchTask: vi.fn(),
|
||||||
|
patchProject: vi.fn(),
|
||||||
|
deployProject: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
const Stub = { template: '<div />' }
|
||||||
|
|
||||||
|
function makeTask(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
completedAt: string | null,
|
||||||
|
updatedAt = '2024-01-10T00:00:00',
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
project_id: 'KIN',
|
||||||
|
title: `Task ${id}`,
|
||||||
|
status,
|
||||||
|
priority: 5,
|
||||||
|
assigned_role: null,
|
||||||
|
parent_task_id: null,
|
||||||
|
brief: null,
|
||||||
|
spec: null,
|
||||||
|
created_at: '2024-01-01T00:00:00',
|
||||||
|
updated_at: updatedAt,
|
||||||
|
completed_at: completedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PROJECT_DATE_FILTER = {
|
||||||
|
id: 'KIN',
|
||||||
|
name: 'Kin',
|
||||||
|
path: '/projects/kin',
|
||||||
|
status: 'active',
|
||||||
|
priority: 5,
|
||||||
|
tech_stack: ['python', 'vue'],
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
total_tasks: 4,
|
||||||
|
done_tasks: 3,
|
||||||
|
active_tasks: 1,
|
||||||
|
blocked_tasks: 0,
|
||||||
|
review_tasks: 0,
|
||||||
|
tasks: [
|
||||||
|
makeTask('KIN-001', 'done', '2024-01-05T12:00:00'), // before range
|
||||||
|
makeTask('KIN-002', 'done', '2024-01-15T12:00:00'), // in middle
|
||||||
|
makeTask('KIN-003', 'done', '2024-01-25T12:00:00'), // after range
|
||||||
|
makeTask('KIN-004', 'pending', null),
|
||||||
|
],
|
||||||
|
decisions: [],
|
||||||
|
modules: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: Stub },
|
||||||
|
{ path: '/project/:id', component: ProjectView, props: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {}
|
||||||
|
return {
|
||||||
|
getItem: (k: string) => store[k] ?? null,
|
||||||
|
setItem: (k: string, v: string) => { store[k] = v },
|
||||||
|
removeItem: (k: string) => { delete store[k] },
|
||||||
|
clear: () => { store = {} },
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear()
|
||||||
|
vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT_DATE_FILTER as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('KIN-126: фильтр по дате выполнения задач', () => {
|
||||||
|
it('1. Date filter inputs отображаются когда выбран статус done', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="date-from"]').exists(),
|
||||||
|
'date-from должен быть виден при status=done',
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="date-to"]').exists(),
|
||||||
|
'date-to должен быть виден при status=done',
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2. Date filter inputs скрыты когда done не выбран', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=pending')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="date-from"]').exists(),
|
||||||
|
'date-from не должен быть виден при status=pending',
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="date-to"]').exists(),
|
||||||
|
'date-to не должен быть виден при status=pending',
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3. Date filter inputs скрыты без фильтра статуса', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('[data-testid="date-from"]').exists(),
|
||||||
|
'date-from не должен быть виден без фильтра статуса',
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4. Фильтрация по dateFrom исключает задачи выполненные до этой даты', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-10')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const links = wrapper.findAll('a[href^="/task/"]')
|
||||||
|
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
|
||||||
|
|
||||||
|
expect(ids, 'KIN-001 (2024-01-05) должен быть исключён').not.toContain('KIN-001')
|
||||||
|
expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
|
||||||
|
expect(ids, 'KIN-003 (2024-01-25) должен быть виден').toContain('KIN-003')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5. Фильтрация по dateTo исключает задачи выполненные после этой даты', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="date-to"]').setValue('2024-01-20')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const links = wrapper.findAll('a[href^="/task/"]')
|
||||||
|
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
|
||||||
|
|
||||||
|
expect(ids, 'KIN-003 (2024-01-25) должен быть исключён').not.toContain('KIN-003')
|
||||||
|
expect(ids, 'KIN-001 (2024-01-05) должен быть виден').toContain('KIN-001')
|
||||||
|
expect(ids, 'KIN-002 (2024-01-15) должен быть виден').toContain('KIN-002')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('6. Пустой результат когда ни одна задача не попадает в диапазон дат', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('[data-testid="date-from"]').setValue('2024-02-01')
|
||||||
|
await wrapper.find('[data-testid="date-to"]').setValue('2024-02-28')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const links = wrapper.findAll('a[href^="/task/"]')
|
||||||
|
expect(links, 'Ни одна done-задача не должна отображаться при несовпадении дат').toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7. Клик по кнопке сброса очищает фильтр и показывает все done-задачи', async () => {
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Устанавливаем dateFrom — остаётся только одна done-задача
|
||||||
|
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(1)
|
||||||
|
|
||||||
|
// Кнопка сброса — ✕ без data-action (не clear-status)
|
||||||
|
const resetBtn = wrapper.findAll('button').find(
|
||||||
|
b => b.text() === '✕' && !b.attributes('data-action'),
|
||||||
|
)
|
||||||
|
expect(resetBtn?.exists(), 'Кнопка сброса дат должна появиться').toBe(true)
|
||||||
|
await resetBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// После сброса — все 3 done-задачи видны
|
||||||
|
expect(wrapper.findAll('a[href^="/task/"]')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8. Fallback на updated_at когда completed_at=null', async () => {
|
||||||
|
const projectWithNullCompletedAt = {
|
||||||
|
...MOCK_PROJECT_DATE_FILTER,
|
||||||
|
tasks: [
|
||||||
|
makeTask('KIN-010', 'done', null, '2024-01-05T00:00:00'), // updated_at early
|
||||||
|
makeTask('KIN-011', 'done', null, '2024-01-25T00:00:00'), // updated_at late
|
||||||
|
],
|
||||||
|
}
|
||||||
|
vi.mocked(api.project).mockResolvedValue(projectWithNullCompletedAt as any)
|
||||||
|
|
||||||
|
const router = makeRouter()
|
||||||
|
await router.push('/project/KIN?status=done')
|
||||||
|
|
||||||
|
const wrapper = mount(ProjectView, {
|
||||||
|
props: { id: 'KIN' },
|
||||||
|
global: { plugins: [router] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// dateFrom='2024-01-20' → KIN-010 (updated_at 2024-01-05) должен быть исключён
|
||||||
|
await wrapper.find('[data-testid="date-from"]').setValue('2024-01-20')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const links = wrapper.findAll('a[href^="/task/"]')
|
||||||
|
const ids = links.map(l => l.attributes('href')?.replace('/task/', '').split('?')[0])
|
||||||
|
|
||||||
|
expect(ids, 'KIN-010 должен быть исключён (updated_at до dateFrom)').not.toContain('KIN-010')
|
||||||
|
expect(ids, 'KIN-011 должен быть виден (updated_at после dateFrom)').toContain('KIN-011')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -122,6 +122,7 @@ export interface Task {
|
||||||
feedback?: string | null
|
feedback?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
completed_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Decision {
|
export interface Decision {
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,9 @@
|
||||||
"settings_integrations_section": "Integrations",
|
"settings_integrations_section": "Integrations",
|
||||||
"settings_execution_mode": "Execution mode",
|
"settings_execution_mode": "Execution mode",
|
||||||
"settings_autocommit": "Autocommit",
|
"settings_autocommit": "Autocommit",
|
||||||
"settings_autocommit_hint": "— git commit after pipeline"
|
"settings_autocommit_hint": "— git commit after pipeline",
|
||||||
|
"done_date_from": "From",
|
||||||
|
"done_date_to": "To"
|
||||||
},
|
},
|
||||||
"escalation": {
|
"escalation": {
|
||||||
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
|
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,9 @@
|
||||||
"settings_integrations_section": "Интеграции",
|
"settings_integrations_section": "Интеграции",
|
||||||
"settings_execution_mode": "Режим выполнения",
|
"settings_execution_mode": "Режим выполнения",
|
||||||
"settings_autocommit": "Автокоммит",
|
"settings_autocommit": "Автокоммит",
|
||||||
"settings_autocommit_hint": "— git commit после pipeline"
|
"settings_autocommit_hint": "— git commit после pipeline",
|
||||||
|
"done_date_from": "От",
|
||||||
|
"done_date_to": "До"
|
||||||
},
|
},
|
||||||
"escalation": {
|
"escalation": {
|
||||||
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
|
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,8 @@ function initStatusFilter(): string[] {
|
||||||
const selectedStatuses = ref<string[]>(initStatusFilter())
|
const selectedStatuses = ref<string[]>(initStatusFilter())
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const taskSearch = ref('')
|
const taskSearch = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
|
|
||||||
function toggleStatus(s: string) {
|
function toggleStatus(s: string) {
|
||||||
const idx = selectedStatuses.value.indexOf(s)
|
const idx = selectedStatuses.value.indexOf(s)
|
||||||
|
|
@ -659,6 +661,16 @@ const filteredTasks = computed(() => {
|
||||||
let tasks = searchFilteredTasks.value
|
let tasks = searchFilteredTasks.value
|
||||||
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
|
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
|
||||||
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
|
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
|
||||||
|
if ((dateFrom.value || dateTo.value) && selectedStatuses.value.includes('done')) {
|
||||||
|
tasks = tasks.filter(t => {
|
||||||
|
if (t.status !== 'done') return true
|
||||||
|
const dateStr = (t.completed_at || t.updated_at) ?? ''
|
||||||
|
const d = dateStr.substring(0, 10)
|
||||||
|
if (dateFrom.value && d < dateFrom.value) return false
|
||||||
|
if (dateTo.value && d > dateTo.value) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
return tasks
|
return tasks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1077,6 +1089,17 @@ async function addDecision() {
|
||||||
<button v-if="taskSearch" @click="taskSearch = ''"
|
<button v-if="taskSearch" @click="taskSearch = ''"
|
||||||
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Date filter for done tasks -->
|
||||||
|
<div v-if="selectedStatuses.includes('done')" class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-600">{{ t('projectView.done_date_from') }}</span>
|
||||||
|
<input type="date" v-model="dateFrom" data-testid="date-from"
|
||||||
|
class="bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 focus:border-gray-500 outline-none" />
|
||||||
|
<span class="text-xs text-gray-600">{{ t('projectView.done_date_to') }}</span>
|
||||||
|
<input type="date" v-model="dateTo" data-testid="date-to"
|
||||||
|
class="bg-gray-800 border border-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 focus:border-gray-500 outline-none" />
|
||||||
|
<button v-if="dateFrom || dateTo" @click="dateFrom = ''; dateTo = ''"
|
||||||
|
class="text-gray-600 hover:text-red-400 text-xs px-1">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Manual escalation tasks -->
|
<!-- Manual escalation tasks -->
|
||||||
<div v-if="manualEscalationTasks.length" class="mb-4">
|
<div v-if="manualEscalationTasks.length" class="mb-4">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue