generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -1111,7 +1111,86 @@ export type AcceptTicketRsp = {
|
||||
ticket: TicketPage | undefined;
|
||||
};
|
||||
|
||||
// Developer tasks use new, in_progress, review and done statuses.
|
||||
export type DeveloperTask = {
|
||||
ID: number | undefined;
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
status: string | undefined;
|
||||
// Omitted assignee means the task is unassigned.
|
||||
assigneeID?: number;
|
||||
assigneeName: string | undefined;
|
||||
authorID?: number;
|
||||
// Calendar deadline in YYYY-MM-DD format; empty means no deadline.
|
||||
dueDate: string | undefined;
|
||||
createdAt: wellKnownTimestamp | undefined;
|
||||
updatedAt: wellKnownTimestamp | undefined;
|
||||
// Required for updates and deletion to detect concurrent edits.
|
||||
version: number | undefined;
|
||||
};
|
||||
|
||||
export type TaskAssignee = {
|
||||
ID: number | undefined;
|
||||
name: string | undefined;
|
||||
};
|
||||
|
||||
export type GetTasksReq = {
|
||||
};
|
||||
|
||||
export type GetTasksRsp = {
|
||||
tasks: DeveloperTask[] | undefined;
|
||||
};
|
||||
|
||||
export type GetTaskAssigneesReq = {
|
||||
};
|
||||
|
||||
export type GetTaskAssigneesRsp = {
|
||||
assignees: TaskAssignee[] | undefined;
|
||||
};
|
||||
|
||||
export type CreateTaskReq = {
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
status: string | undefined;
|
||||
assigneeID?: number;
|
||||
dueDate: string | undefined;
|
||||
};
|
||||
|
||||
export type UpdateTaskReq = {
|
||||
ID: number | undefined;
|
||||
title: string | undefined;
|
||||
description: string | undefined;
|
||||
status: string | undefined;
|
||||
assigneeID?: number;
|
||||
dueDate: string | undefined;
|
||||
version: number | undefined;
|
||||
};
|
||||
|
||||
export type SetTaskStatusReq = {
|
||||
ID: number | undefined;
|
||||
status: string | undefined;
|
||||
version: number | undefined;
|
||||
};
|
||||
|
||||
export type DeleteTaskReq = {
|
||||
ID: number | undefined;
|
||||
version: number | undefined;
|
||||
};
|
||||
|
||||
export type TaskRsp = {
|
||||
task: DeveloperTask | undefined;
|
||||
};
|
||||
|
||||
export type DeleteTaskRsp = {
|
||||
};
|
||||
|
||||
export interface EveningDetectiveServer {
|
||||
GetTasks(request: GetTasksReq): Promise<GetTasksRsp>;
|
||||
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
|
||||
CreateTask(request: CreateTaskReq): Promise<TaskRsp>;
|
||||
UpdateTask(request: UpdateTaskReq): Promise<TaskRsp>;
|
||||
SetTaskStatus(request: SetTaskStatusReq): Promise<TaskRsp>;
|
||||
DeleteTask(request: DeleteTaskReq): Promise<DeleteTaskRsp>;
|
||||
GetParticipation(request: GetParticipationReq): Promise<ParticipationRsp>;
|
||||
AnswerParticipation(request: AnswerParticipationReq): Promise<ParticipationRsp>;
|
||||
Ping(request: PingReq): Promise<PingRsp>;
|
||||
@@ -1234,6 +1313,117 @@ export function createEveningDetectiveServerClient(
|
||||
handler: RequestHandler
|
||||
): EveningDetectiveServer {
|
||||
return {
|
||||
GetTasks(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/tasks`; // eslint-disable-line quotes
|
||||
const body = null;
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "GET",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "GetTasks",
|
||||
}) as Promise<GetTasksRsp>;
|
||||
},
|
||||
GetTaskAssignees(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/tasks/assignees`; // eslint-disable-line quotes
|
||||
const body = null;
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "GET",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "GetTaskAssignees",
|
||||
}) as Promise<GetTaskAssigneesRsp>;
|
||||
},
|
||||
CreateTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/tasks`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "CreateTask",
|
||||
}) as Promise<TaskRsp>;
|
||||
},
|
||||
UpdateTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
}
|
||||
const path = `api/tasks/${request.ID}`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "PUT",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "UpdateTask",
|
||||
}) as Promise<TaskRsp>;
|
||||
},
|
||||
SetTaskStatus(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
}
|
||||
const path = `api/tasks/${request.ID}/status`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "SetTaskStatus",
|
||||
}) as Promise<TaskRsp>;
|
||||
},
|
||||
DeleteTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
if (!request.ID) {
|
||||
throw new Error("missing required field request.ID");
|
||||
}
|
||||
const path = `api/tasks/${request.ID}/delete`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
const queryParams: string[] = [];
|
||||
let uri = path;
|
||||
if (queryParams.length > 0) {
|
||||
uri += `?${queryParams.join("&")}`
|
||||
}
|
||||
return handler({
|
||||
path: uri,
|
||||
method: "POST",
|
||||
body,
|
||||
}, {
|
||||
service: "EveningDetectiveServer",
|
||||
method: "DeleteTask",
|
||||
}) as Promise<DeleteTaskRsp>;
|
||||
},
|
||||
GetParticipation(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const path = `api/participation/get`; // eslint-disable-line quotes
|
||||
const body = JSON.stringify(request);
|
||||
|
||||
@@ -112,6 +112,15 @@ const openMenu = ref(false)
|
||||
>
|
||||
Сценарии
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPermission('tasks_page')"
|
||||
type="button"
|
||||
class="menu-item-block"
|
||||
:class="{ active: props.active == 'tasks' }"
|
||||
@click="router.push('/tasks')"
|
||||
>
|
||||
Задачи
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-item-block"
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
dateRuRU,
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NConfigProvider,
|
||||
NDatePicker,
|
||||
NEmpty,
|
||||
NFlex,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSelect,
|
||||
NSpin,
|
||||
NTag,
|
||||
ruRU,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { DeveloperTask, TaskAssignee } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { isTaskOverdue } from '@/utils/taskDates'
|
||||
|
||||
const client = getAuthClient()
|
||||
const message = useMessage()
|
||||
const statuses = [
|
||||
{ label: 'Новая', value: 'new' },
|
||||
{ label: 'В работе', value: 'in_progress' },
|
||||
{ label: 'На проверке', value: 'review' },
|
||||
{ label: 'Готово', value: 'done' },
|
||||
]
|
||||
const tasks = ref<DeveloperTask[]>([])
|
||||
const assignees = ref<TaskAssignee[]>([])
|
||||
const hasAccess = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const loadError = ref('')
|
||||
const selectedAssignee = ref<number | 'all'>('all')
|
||||
const isFormOpen = ref(false)
|
||||
const editingID = ref<number | undefined>()
|
||||
const editingVersion = ref<number | undefined>()
|
||||
const deleteTarget = ref<DeveloperTask | null>(null)
|
||||
const draggedID = ref<number | undefined>()
|
||||
const form = reactive<{
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
assigneeID: number | null
|
||||
dueDate: string | null
|
||||
}>({
|
||||
title: '',
|
||||
description: '',
|
||||
status: 'new',
|
||||
assigneeID: null,
|
||||
dueDate: null,
|
||||
})
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
assignees.value.map((user) => ({ label: user.name || `#${user.ID}`, value: user.ID! })),
|
||||
)
|
||||
const filterOptions = computed(() => [
|
||||
{ label: 'Все ответственные', value: 'all' },
|
||||
{ label: 'Без ответственного', value: 0 },
|
||||
...assigneeOptions.value,
|
||||
...tasks.value
|
||||
.filter(
|
||||
(task) => task.assigneeID && !assignees.value.some((user) => user.ID === task.assigneeID),
|
||||
)
|
||||
.filter(
|
||||
(task, index, items) =>
|
||||
items.findIndex((item) => item.assigneeID === task.assigneeID) === index,
|
||||
)
|
||||
.map((task) => ({
|
||||
label: task.assigneeName || `#${task.assigneeID}`,
|
||||
value: task.assigneeID!,
|
||||
})),
|
||||
])
|
||||
const formAssigneeOptions = computed(() => {
|
||||
if (
|
||||
!form.assigneeID ||
|
||||
assigneeOptions.value.some((option) => option.value === form.assigneeID)
|
||||
) {
|
||||
return assigneeOptions.value
|
||||
}
|
||||
const current = tasks.value.find((task) => task.ID === editingID.value)
|
||||
return [
|
||||
...assigneeOptions.value,
|
||||
{
|
||||
label: `${current?.assigneeName || `#${form.assigneeID}`} (роль отозвана)`,
|
||||
value: form.assigneeID,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
const columns = computed(() =>
|
||||
statuses.map((status) => ({
|
||||
...status,
|
||||
tasks: tasks.value.filter(
|
||||
(task) =>
|
||||
task.status === status.value &&
|
||||
(selectedAssignee.value === 'all' || (task.assigneeID || 0) === selectedAssignee.value),
|
||||
),
|
||||
})),
|
||||
)
|
||||
const latestTask = computed(() => tasks.value.find((task) => task.ID === editingID.value))
|
||||
const titleError = computed(() => {
|
||||
if (!form.title.trim()) return 'Введите название задачи'
|
||||
return [...form.title.trim()].length > 255 ? 'Не более 255 символов' : undefined
|
||||
})
|
||||
const descriptionError = computed(() =>
|
||||
[...form.description].length > 10000 ? 'Не более 10 000 символов' : undefined,
|
||||
)
|
||||
const hasConflict = computed(
|
||||
() =>
|
||||
editingID.value !== undefined &&
|
||||
latestTask.value !== undefined &&
|
||||
latestTask.value.version !== editingVersion.value,
|
||||
)
|
||||
const isDeleted = computed(() => editingID.value !== undefined && !latestTask.value)
|
||||
|
||||
function statusLabel(status: string | undefined) {
|
||||
return statuses.find((item) => item.value === status)?.label || status
|
||||
}
|
||||
|
||||
function formatDate(value: string | undefined) {
|
||||
return value ? value.split('-').reverse().join('.') : 'Без срока'
|
||||
}
|
||||
|
||||
async function loadBoard() {
|
||||
if (isLoading.value || isSaving.value) return
|
||||
isLoading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const permissions = await client.GetPermissions({})
|
||||
hasAccess.value = permissions.permissions?.includes('tasks_page') || false
|
||||
if (!hasAccess.value) return
|
||||
const [taskResponse, assigneeResponse] = await Promise.all([
|
||||
client.GetTasks({}),
|
||||
client.GetTaskAssignees({}),
|
||||
])
|
||||
tasks.value = taskResponse.tasks || []
|
||||
assignees.value = assigneeResponse.assignees || []
|
||||
} catch (err) {
|
||||
loadError.value =
|
||||
err instanceof Error && err.message === 'unauthorized'
|
||||
? 'Войдите под учётной записью разработчика или администратора'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Не удалось загрузить задачи'
|
||||
message.error(loadError.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openForm(task?: DeveloperTask) {
|
||||
if (isSaving.value) return
|
||||
editingID.value = task?.ID
|
||||
editingVersion.value = task?.version
|
||||
Object.assign(form, {
|
||||
title: task?.title || '',
|
||||
description: task?.description || '',
|
||||
status: task?.status || 'new',
|
||||
assigneeID: task?.assigneeID ?? null,
|
||||
dueDate: task?.dueDate || null,
|
||||
})
|
||||
isFormOpen.value = true
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
if (isSaving.value || isLoading.value || hasConflict.value || isDeleted.value) return
|
||||
const title = form.title.trim()
|
||||
if (!title || [...title].length > 255 || [...form.description].length > 10000) {
|
||||
message.error('Введите название до 255 символов и описание до 10 000 символов')
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title,
|
||||
description: form.description,
|
||||
status: form.status,
|
||||
assigneeID: form.assigneeID ?? undefined,
|
||||
dueDate: form.dueDate || '',
|
||||
}
|
||||
const result =
|
||||
editingID.value !== undefined
|
||||
? await client.UpdateTask({
|
||||
...payload,
|
||||
ID: editingID.value,
|
||||
version: editingVersion.value,
|
||||
})
|
||||
: await client.CreateTask(payload)
|
||||
if (result.task) {
|
||||
const saved = result.task
|
||||
tasks.value =
|
||||
editingID.value !== undefined
|
||||
? tasks.value.map((task) => (task.ID === saved.ID ? saved : task))
|
||||
: [saved, ...tasks.value]
|
||||
}
|
||||
isFormOpen.value = false
|
||||
message.success('Задача сохранена')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить задачу')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
await loadBoard()
|
||||
}
|
||||
|
||||
function startDrag(event: DragEvent, task: DeveloperTask) {
|
||||
if (isSaving.value || isLoading.value || !event.dataTransfer) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
draggedID.value = task.ID
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', String(task.ID))
|
||||
}
|
||||
|
||||
async function dropTask(status: string) {
|
||||
const task = tasks.value.find((item) => item.ID === draggedID.value)
|
||||
draggedID.value = undefined
|
||||
if (!task || task.status === status || isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const result = await client.SetTaskStatus({ ID: task.ID, status, version: task.version })
|
||||
if (result.task) {
|
||||
const saved = result.task
|
||||
tasks.value = tasks.value.map((item) => (item.ID === task.ID ? saved : item))
|
||||
}
|
||||
message.success('Статус изменён')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось изменить статус')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
await loadBoard()
|
||||
}
|
||||
|
||||
async function deleteTask() {
|
||||
if (!deleteTarget.value || isSaving.value || isLoading.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
await client.DeleteTask({ ID: deleteTarget.value.ID, version: deleteTarget.value.version })
|
||||
tasks.value = tasks.value.filter((task) => task.ID !== deleteTarget.value?.ID)
|
||||
deleteTarget.value = null
|
||||
isFormOpen.value = false
|
||||
message.success('Задача удалена')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось удалить задачу')
|
||||
deleteTarget.value = null
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
await loadBoard()
|
||||
}
|
||||
|
||||
function useLatestVersion() {
|
||||
editingVersion.value = latestTask.value?.version
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isLoading.value = false
|
||||
void loadBoard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :locale="ruRU" :date-locale="dateRuRU">
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200">
|
||||
<n-flex class="toolbar" justify="space-between" align="center">
|
||||
<h1>Задачи</h1>
|
||||
<n-flex>
|
||||
<n-button :loading="isLoading" :disabled="isSaving" @click="loadBoard"
|
||||
>Обновить</n-button
|
||||
>
|
||||
<n-button
|
||||
v-if="hasAccess"
|
||||
type="primary"
|
||||
:disabled="isSaving || isLoading"
|
||||
@click="openForm()"
|
||||
>
|
||||
Создать задачу
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-alert v-if="loadError" type="error" class="notice">{{ loadError }}</n-alert>
|
||||
<n-spin v-if="isLoading && !hasAccess" class="initial-loading" />
|
||||
<n-alert v-else-if="!hasAccess && !loadError" type="warning">
|
||||
Страница доступна разработчикам и администратору.
|
||||
</n-alert>
|
||||
<template v-if="hasAccess">
|
||||
<n-select
|
||||
v-model:value="selectedAssignee"
|
||||
:options="filterOptions"
|
||||
aria-label="Фильтр по ответственному"
|
||||
class="assignee-filter"
|
||||
/>
|
||||
<n-spin :show="isLoading || isSaving">
|
||||
<div class="board">
|
||||
<section
|
||||
v-for="column in columns"
|
||||
:key="column.value"
|
||||
class="column"
|
||||
:aria-label="column.label"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropTask(column.value)"
|
||||
>
|
||||
<n-flex justify="space-between" align="center" class="column-heading">
|
||||
<h2>{{ column.label }}</h2>
|
||||
<n-tag size="small">{{ column.tasks.length }}</n-tag>
|
||||
</n-flex>
|
||||
<n-empty v-if="!column.tasks.length" description="Нет задач" class="empty-column" />
|
||||
<n-card
|
||||
v-for="task in column.tasks"
|
||||
:key="task.ID"
|
||||
class="task-card"
|
||||
size="small"
|
||||
:draggable="!isSaving && !isLoading"
|
||||
@dragstart="startDrag($event, task)"
|
||||
@dragend="draggedID = undefined"
|
||||
>
|
||||
<div class="task-id">#{{ task.ID }}</div>
|
||||
<h3 class="task-title">{{ task.title }}</h3>
|
||||
<p>{{ task.assigneeName || 'Без ответственного' }}</p>
|
||||
<n-flex align="center" class="task-due">
|
||||
<span>{{ formatDate(task.dueDate) }}</span>
|
||||
<n-tag v-if="isTaskOverdue(task)" type="error" size="small">Просрочена</n-tag>
|
||||
</n-flex>
|
||||
<n-button size="small" :disabled="isSaving || isLoading" @click="openForm(task)">
|
||||
Открыть задачу
|
||||
</n-button>
|
||||
</n-card>
|
||||
</section>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderMenu active="tasks" />
|
||||
|
||||
<n-modal v-model:show="isFormOpen" :mask-closable="!isSaving" :close-on-esc="!isSaving">
|
||||
<n-card
|
||||
class="task-modal"
|
||||
:title="editingID ? `Задача #${editingID}` : 'Новая задача'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<n-alert v-if="isDeleted" type="warning" class="notice">
|
||||
Задача удалена другим пользователем. Черновик сохранён в этой форме.
|
||||
</n-alert>
|
||||
<n-alert v-if="hasConflict && latestTask" type="warning" class="notice">
|
||||
Задача изменена другим пользователем. Ваш черновик сохранён. Актуальные данные:
|
||||
<p>{{ latestTask.title }} — {{ statusLabel(latestTask.status) }}</p>
|
||||
<p>
|
||||
{{ latestTask.assigneeName || 'Без ответственного' }} ·
|
||||
{{ formatDate(latestTask.dueDate) }}
|
||||
</p>
|
||||
<p class="server-description">{{ latestTask.description || 'Без описания' }}</p>
|
||||
<n-button :disabled="isLoading || isSaving" @click="useLatestVersion">
|
||||
Продолжить с моим черновиком
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<n-form-item
|
||||
label="Название *"
|
||||
label-for="task-title"
|
||||
:feedback="titleError"
|
||||
:validation-status="titleError ? 'error' : undefined"
|
||||
>
|
||||
<n-input
|
||||
:input-props="{ id: 'task-title' }"
|
||||
v-model:value="form.title"
|
||||
:disabled="isSaving"
|
||||
placeholder="Название задачи"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="Описание"
|
||||
label-for="task-description"
|
||||
:feedback="descriptionError"
|
||||
:validation-status="descriptionError ? 'error' : undefined"
|
||||
>
|
||||
<n-input
|
||||
:input-props="{ id: 'task-description' }"
|
||||
v-model:value="form.description"
|
||||
type="textarea"
|
||||
:disabled="isSaving"
|
||||
placeholder="Что нужно сделать"
|
||||
:autosize="{ minRows: 3, maxRows: 10 }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Статус" label-for="task-status">
|
||||
<n-select
|
||||
:input-props="{ id: 'task-status', 'aria-label': 'Статус' }"
|
||||
filterable
|
||||
v-model:value="form.status"
|
||||
:options="statuses"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Ответственный" label-for="task-assignee">
|
||||
<n-select
|
||||
:input-props="{ id: 'task-assignee', 'aria-label': 'Ответственный' }"
|
||||
v-model:value="form.assigneeID"
|
||||
:options="formAssigneeOptions"
|
||||
:disabled="isSaving"
|
||||
placeholder="Без ответственного"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Срок выполнения" label-for="task-due-date">
|
||||
<n-date-picker
|
||||
id="task-due-date"
|
||||
v-model:formatted-value="form.dueDate"
|
||||
type="date"
|
||||
placeholder="Дата завершения"
|
||||
value-format="yyyy-MM-dd"
|
||||
format="dd.MM.yyyy"
|
||||
:disabled="isSaving"
|
||||
clearable
|
||||
class="date-picker"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-flex justify="space-between">
|
||||
<n-button
|
||||
v-if="editingID && latestTask"
|
||||
type="error"
|
||||
:disabled="isSaving || isLoading"
|
||||
@click="deleteTarget = latestTask"
|
||||
>Удалить</n-button
|
||||
>
|
||||
<n-flex>
|
||||
<n-button :disabled="isSaving" @click="isFormOpen = false">Закрыть</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isSaving"
|
||||
:disabled="
|
||||
isLoading || hasConflict || isDeleted || !!titleError || !!descriptionError
|
||||
"
|
||||
@click="saveTask"
|
||||
>Сохранить</n-button
|
||||
>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
:show="!!deleteTarget"
|
||||
:mask-closable="!isSaving"
|
||||
:close-on-esc="!isSaving"
|
||||
@update:show="!isSaving && (deleteTarget = null)"
|
||||
>
|
||||
<n-card class="task-modal" title="Удалить задачу?" role="dialog" aria-modal="true">
|
||||
<p>Удалить «{{ deleteTarget?.title }}»? Это действие необратимо.</p>
|
||||
<n-flex justify="end" class="delete-actions">
|
||||
<n-button :disabled="isSaving" @click="deleteTarget = null">Отмена</n-button>
|
||||
<n-button type="error" :loading="isSaving" @click="deleteTask">Удалить</n-button>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center-block-custom {
|
||||
height: calc(100dvh - 70px);
|
||||
}
|
||||
|
||||
.width1200 {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.assignee-filter {
|
||||
margin-bottom: 16px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, minmax(260px, 1fr));
|
||||
overflow-x: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.column {
|
||||
background: var(--vt-c-black-soft);
|
||||
border: 1px solid var(--vt-c-divider-dark-2);
|
||||
border-radius: 8px;
|
||||
min-height: 240px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.column-heading {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.column-heading h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
color: var(--vt-c-text-dark-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.empty-column {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.task-modal {
|
||||
max-width: 640px;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.server-description {
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.delete-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.initial-loading {
|
||||
display: block;
|
||||
margin: 32px auto;
|
||||
}
|
||||
</style>
|
||||
@@ -93,6 +93,13 @@ async function deleteRole(userId: number, role: string) {
|
||||
v-if="auth.hasRole('admin') && auth.user && user.ID !== auth.user.ID"
|
||||
@click="passwordUser = user"
|
||||
>Изменить пароль</n-button>
|
||||
<n-button
|
||||
v-if="!user.roles?.includes('developer')"
|
||||
@click="addRole(user.ID!, 'developer')"
|
||||
>Выдать роль разработчика</n-button>
|
||||
<n-button v-else @click="deleteRole(user.ID!, 'developer')"
|
||||
>Забрать роль разработчика</n-button
|
||||
>
|
||||
<n-button @click="addRole(user.ID!, 'author')" v-if="!user.roles?.includes('author')"
|
||||
>Сделать автором</n-button
|
||||
>
|
||||
|
||||
@@ -88,6 +88,11 @@ const router = createRouter({
|
||||
name: 'scenario',
|
||||
component: () => import('../views/ScenarioView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'tasks',
|
||||
component: () => import('../views/TasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { isTaskOverdue } from '../taskDates.ts'
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'past date', status: 'new', dueDate: '2026-10-06', expected: true },
|
||||
{ name: 'today remains on time', status: 'review', dueDate: '2026-10-07', expected: false },
|
||||
{ name: 'future date', status: 'in_progress', dueDate: '2026-10-08', expected: false },
|
||||
{ name: 'finished task', status: 'done', dueDate: '2026-10-06', expected: false },
|
||||
{ name: 'no deadline', status: 'new', dueDate: '', expected: false },
|
||||
]) {
|
||||
test(scenario.name, () => {
|
||||
const now = new Date(2026, 9, 7, 0, 1)
|
||||
assert.equal(isTaskOverdue(scenario, now), scenario.expected)
|
||||
})
|
||||
}
|
||||
|
||||
test('local date is used at the UTC boundary', () => {
|
||||
const now = new Date(2026, 9, 7, 0, 1)
|
||||
assert.equal(isTaskOverdue({ status: 'new', dueDate: '2026-10-07' }, now), false)
|
||||
assert.equal(isTaskOverdue({ status: 'new', dueDate: '2026-10-06' }, now), true)
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { DeveloperTask } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
export function isTaskOverdue(
|
||||
task: Pick<DeveloperTask, 'status' | 'dueDate'>,
|
||||
now = new Date(),
|
||||
): boolean {
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
return task.status !== 'done' && !!task.dueDate && task.dueDate < today
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import TasksPage from '@/components/TasksPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TasksPage />
|
||||
</template>
|
||||
Reference in New Issue
Block a user