feat(KIN-012): UI auto/review mode toggle, autopilot indicator, persist project mode in DB
- TaskDetail: hide Approve/Reject buttons in auto mode, show "Автопилот активен" badge
- TaskDetail: execution_mode persisted per-task via PATCH /api/tasks/{id}
- TaskDetail: loadMode reads DB value, falls back to localStorage per project
- TaskDetail: back navigation preserves status filter via ?back_status query param
- ProjectView: toggleMode now persists to DB via PATCH /api/projects/{id}
- ProjectView: loadMode reads project.execution_mode from DB first
- ProjectView: task list shows 🔓 badge for auto-mode tasks
- ProjectView: status filter synced to URL query param ?status=
- api.ts: add patchProject(), execution_mode field on Project interface
- core/db.py, core/models.py: execution_mode columns + migration for projects & tasks
- web/api.py: PATCH /api/projects/{id} and PATCH /api/tasks/{id} support execution_mode
- tests: 256 tests pass, new test_auto_mode.py with 60+ auto mode tests
- frontend: vitest config added for component tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3cb516193b
commit
4a27bf0693
12 changed files with 2698 additions and 30 deletions
280
web/frontend/src/__tests__/filter-persistence.test.ts
Normal file
280
web/frontend/src/__tests__/filter-persistence.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
/**
|
||||
* KIN-011: Тесты сохранения фильтра статусов при навигации
|
||||
*
|
||||
* Проверяет:
|
||||
* 1. Выбор фильтра обновляет URL (?status=...)
|
||||
* 2. Прямая ссылка с query param инициализирует фильтр
|
||||
* 3. Фильтр показывает только задачи с нужным статусом
|
||||
* 4. Сброс фильтра удаляет param из URL
|
||||
* 5. goBack() вызывает router.back() при наличии истории
|
||||
* 6. goBack() делает push на /project/:id без истории
|
||||
* 7. После router.back() URL проекта восстанавливается с фильтром
|
||||
*/
|
||||
|
||||
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'
|
||||
import TaskDetail from '../views/TaskDetail.vue'
|
||||
|
||||
// Мок api — factory без ссылок на внешние переменные (vi.mock хоистится)
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
project: vi.fn(),
|
||||
taskFull: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
auditProject: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Импортируем мок после объявления vi.mock
|
||||
import { api } from '../api'
|
||||
|
||||
const Stub = { template: '<div />' }
|
||||
|
||||
const MOCK_PROJECT = {
|
||||
id: 'KIN',
|
||||
name: 'Kin',
|
||||
path: '/projects/kin',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python', 'vue'],
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: 3,
|
||||
done_tasks: 1,
|
||||
active_tasks: 1,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
tasks: [
|
||||
{
|
||||
id: 'KIN-001', project_id: 'KIN', title: 'Task 1', status: 'pending',
|
||||
priority: 5, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'KIN-002', project_id: 'KIN', title: 'Task 2', status: 'in_progress',
|
||||
priority: 3, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'KIN-003', project_id: 'KIN', title: 'Task 3', status: 'done',
|
||||
priority: 1, assigned_role: null, parent_task_id: null,
|
||||
brief: null, spec: null, created_at: '2024-01-01', updated_at: '2024-01-01',
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
modules: [],
|
||||
}
|
||||
|
||||
const MOCK_TASK_FULL = {
|
||||
id: 'KIN-002',
|
||||
project_id: 'KIN',
|
||||
title: 'Task 2',
|
||||
status: 'in_progress',
|
||||
priority: 3,
|
||||
assigned_role: null,
|
||||
parent_task_id: null,
|
||||
brief: null,
|
||||
spec: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
pipeline_steps: [],
|
||||
related_decisions: [],
|
||||
}
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Stub },
|
||||
{ path: '/project/:id', component: ProjectView, props: true },
|
||||
{ path: '/task/:id', component: TaskDetail, props: true },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// localStorage mock для jsdom-окружения
|
||||
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 as any)
|
||||
vi.mocked(api.taskFull).mockResolvedValue(MOCK_TASK_FULL as any)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// ProjectView: фильтр ↔ URL
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-011: ProjectView — фильтр и URL', () => {
|
||||
it('1. При выборе фильтра URL обновляется query param ?status', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Изначально status нет в URL
|
||||
expect(router.currentRoute.value.query.status).toBeUndefined()
|
||||
|
||||
// Меняем фильтр через select (первый select — фильтр статусов)
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('in_progress')
|
||||
await flushPromises()
|
||||
|
||||
// URL должен содержать ?status=in_progress
|
||||
expect(router.currentRoute.value.query.status).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('2. Прямая ссылка ?status=in_progress инициализирует фильтр в select', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// select должен показывать in_progress
|
||||
const select = wrapper.find('select')
|
||||
expect((select.element as HTMLSelectElement).value).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('3. Прямая ссылка ?status=in_progress показывает только задачи с этим статусом', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Должна быть видна только KIN-002 (in_progress)
|
||||
const links = wrapper.findAll('a[href^="/task/"]')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0].text()).toContain('KIN-002')
|
||||
})
|
||||
|
||||
it('4. Сброс фильтра (пустое значение) удаляет status из URL', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=done')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Сброс фильтра
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('')
|
||||
await flushPromises()
|
||||
|
||||
// status должен исчезнуть из URL
|
||||
expect(router.currentRoute.value.query.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('5. Без фильтра отображаются все 3 задачи', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN')
|
||||
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'KIN' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const links = wrapper.findAll('a[href^="/task/"]')
|
||||
expect(links).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TaskDetail: goBack сохраняет URL проекта с фильтром
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-011: TaskDetail — возврат с сохранением URL', () => {
|
||||
it('6. goBack() вызывает router.back() когда window.history.length > 1', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
const backSpy = vi.spyOn(router, 'back')
|
||||
|
||||
// Эмулируем наличие истории
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { ...window.history, length: 3 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(TaskDetail, {
|
||||
props: { id: 'KIN-002' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
// Первая кнопка — кнопка "назад" (← KIN)
|
||||
const backBtn = wrapper.find('button')
|
||||
await backBtn.trigger('click')
|
||||
|
||||
expect(backSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('7. goBack() без истории делает push на /project/:id', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
const pushSpy = vi.spyOn(router, 'push')
|
||||
|
||||
// Эмулируем отсутствие истории
|
||||
Object.defineProperty(window, 'history', {
|
||||
value: { ...window.history, length: 1 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const wrapper = mount(TaskDetail, {
|
||||
props: { id: 'KIN-002' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const backBtn = wrapper.find('button')
|
||||
await backBtn.trigger('click')
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({ path: '/project/KIN', query: undefined })
|
||||
})
|
||||
|
||||
it('8. После router.back() URL проекта восстанавливается с query param ?status', async () => {
|
||||
const router = makeRouter()
|
||||
|
||||
// Навигация: проект с фильтром → задача
|
||||
await router.push('/project/KIN?status=in_progress')
|
||||
await router.push('/task/KIN-002')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/task/KIN-002')
|
||||
|
||||
// Возвращаемся назад
|
||||
router.back()
|
||||
await flushPromises()
|
||||
|
||||
// URL должен вернуться к /project/KIN?status=in_progress
|
||||
expect(router.currentRoute.value.path).toBe('/project/KIN')
|
||||
expect(router.currentRoute.value.query.status).toBe('in_progress')
|
||||
})
|
||||
})
|
||||
|
|
@ -33,6 +33,7 @@ export interface Project {
|
|||
status: string
|
||||
priority: number
|
||||
tech_stack: string[] | null
|
||||
execution_mode: string | null
|
||||
created_at: string
|
||||
total_tasks: number
|
||||
done_tasks: number
|
||||
|
|
@ -57,6 +58,7 @@ export interface Task {
|
|||
parent_task_id: string | null
|
||||
brief: Record<string, unknown> | null
|
||||
spec: Record<string, unknown> | null
|
||||
execution_mode: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -158,6 +160,8 @@ export const api = {
|
|||
post<AuditResult>(`/projects/${projectId}/audit`, {}),
|
||||
auditApply: (projectId: string, taskIds: string[]) =>
|
||||
post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }),
|
||||
patchTask: (id: string, data: { status: string }) =>
|
||||
patchTask: (id: string, data: { status?: string; execution_mode?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, data),
|
||||
patchProject: (id: string, data: { execution_mode: string }) =>
|
||||
patch<Project>(`/projects/${id}`, data),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -12,7 +15,7 @@ const error = ref('')
|
|||
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
|
||||
|
||||
// Filters
|
||||
const taskStatusFilter = ref('')
|
||||
const taskStatusFilter = ref((route.query.status as string) || '')
|
||||
const decisionTypeFilter = ref('')
|
||||
const decisionSearch = ref('')
|
||||
|
||||
|
|
@ -20,12 +23,22 @@ const decisionSearch = ref('')
|
|||
const autoMode = ref(false)
|
||||
|
||||
function loadMode() {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
|
||||
if (project.value?.execution_mode) {
|
||||
autoMode.value = project.value.execution_mode === 'auto'
|
||||
} else {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
async function toggleMode() {
|
||||
autoMode.value = !autoMode.value
|
||||
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
|
||||
try {
|
||||
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
|
||||
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto' : 'review' }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Audit
|
||||
|
|
@ -85,6 +98,10 @@ async function load() {
|
|||
}
|
||||
}
|
||||
|
||||
watch(taskStatusFilter, (val) => {
|
||||
router.replace({ query: { ...route.query, status: val || undefined } })
|
||||
})
|
||||
|
||||
onMounted(() => { load(); loadMode() })
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
|
|
@ -267,12 +284,15 @@ async function addDecision() {
|
|||
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
|
||||
<div v-else class="space-y-1">
|
||||
<router-link v-for="t in filteredTasks" :key="t.id"
|
||||
:to="`/task/${t.id}`"
|
||||
:to="{ path: `/task/${t.id}`, query: taskStatusFilter ? { back_status: taskStatusFilter } : undefined }"
|
||||
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)" />
|
||||
<span class="text-gray-300 truncate">{{ t.title }}</span>
|
||||
<span v-if="t.execution_mode === 'auto'"
|
||||
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
|
||||
title="Auto mode">🔓</span>
|
||||
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const task = ref<TaskFull | null>(null)
|
||||
const loading = ref(true)
|
||||
|
|
@ -25,17 +28,27 @@ const resolvingAction = ref(false)
|
|||
const showReject = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
// Auto/Review mode (persisted per project)
|
||||
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
|
||||
const autoMode = ref(false)
|
||||
|
||||
function loadMode(projectId: string) {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto'
|
||||
function loadMode(t: typeof task.value) {
|
||||
if (!t) return
|
||||
if (t.execution_mode) {
|
||||
autoMode.value = t.execution_mode === 'auto'
|
||||
} else {
|
||||
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
async function toggleMode() {
|
||||
if (!task.value) return
|
||||
autoMode.value = !autoMode.value
|
||||
if (task.value) {
|
||||
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
|
||||
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
|
||||
try {
|
||||
const updated = await api.patchTask(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
|
||||
task.value = { ...task.value, ...updated }
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +56,7 @@ async function load() {
|
|||
try {
|
||||
const prev = task.value
|
||||
task.value = await api.taskFull(props.id)
|
||||
if (task.value?.project_id) loadMode(task.value.project_id)
|
||||
loadMode(task.value)
|
||||
// Auto-start polling if task is in_progress
|
||||
if (task.value.status === 'in_progress' && !polling.value) {
|
||||
startPolling()
|
||||
|
|
@ -186,6 +199,18 @@ async function runPipeline() {
|
|||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else if (task.value) {
|
||||
const backStatus = route.query.back_status as string | undefined
|
||||
router.push({
|
||||
path: `/project/${task.value.project_id}`,
|
||||
query: backStatus ? { status: backStatus } : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const statusChanging = ref(false)
|
||||
|
||||
async function changeStatus(newStatus: string) {
|
||||
|
|
@ -209,14 +234,17 @@ async function changeStatus(newStatus: string) {
|
|||
<!-- 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">
|
||||
<button @click="goBack" class="text-gray-600 hover:text-gray-400 text-sm cursor-pointer bg-transparent border-none p-0">
|
||||
← {{ task.project_id }}
|
||||
</router-link>
|
||||
</button>
|
||||
</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 v-if="task.execution_mode === 'auto'"
|
||||
class="text-[10px] px-1.5 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded"
|
||||
title="Auto mode: agents can write files">🔓 auto</span>
|
||||
<select
|
||||
:value="task.status"
|
||||
@change="changeStatus(($event.target as HTMLSelectElement).value)"
|
||||
|
|
@ -303,12 +331,17 @@ async function changeStatus(newStatus: string) {
|
|||
|
||||
<!-- 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'"
|
||||
<div v-if="autoMode && (isRunning || task.status === 'review')"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
|
||||
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
|
||||
Автопилот активен
|
||||
</div>
|
||||
<button v-if="task.status === 'review' && !autoMode"
|
||||
@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'"
|
||||
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
|
||||
@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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue