2026-08-11 23:17:04 +07:00
|
|
|
<template>
|
|
|
|
|
<div>
|
|
|
|
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
label="List"
|
|
|
|
|
icon="pi pi-list"
|
|
|
|
|
severity="secondary"
|
|
|
|
|
outlined
|
|
|
|
|
:to="{ name: 'tasks' }"
|
|
|
|
|
/>
|
|
|
|
|
<Button label="Board" icon="pi pi-th-large" severity="secondary" outlined :to="{ name: 'tasks-board' }" />
|
|
|
|
|
</div>
|
|
|
|
|
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="flex items-start gap-4 overflow-x-auto pb-4">
|
|
|
|
|
<div
|
|
|
|
|
v-for="col in columns"
|
|
|
|
|
:key="col.status"
|
2026-08-24 08:07:23 +07:00
|
|
|
class="min-w-[260px] flex-1 rounded-2xl p-2" style="background: var(--canvas)"
|
2026-08-11 23:17:04 +07:00
|
|
|
@dragover.prevent="dragOverStatus = col.status"
|
|
|
|
|
@dragleave="dragOverStatus = null"
|
|
|
|
|
@drop.prevent="onDrop(col.status)"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center justify-between px-3 py-2 font-semibold">
|
|
|
|
|
<span>{{ col.label }}</span>
|
|
|
|
|
<Tag :value="tasksIn(col.status).length" severity="secondary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-for="task in tasksIn(col.status)"
|
|
|
|
|
:key="task.id"
|
2026-08-24 08:07:23 +07:00
|
|
|
class="task-card mb-2 cursor-pointer rounded-xl border p-3"
|
|
|
|
|
:style="{ background: 'var(--panel)', borderColor: 'var(--hairline)' }"
|
2026-08-11 23:17:04 +07:00
|
|
|
:class="{ 'opacity-40': draggingId === task.id }"
|
|
|
|
|
:draggable="auth.can('tasks', 'edit')"
|
|
|
|
|
@dragstart="onDragStart(task)"
|
|
|
|
|
@dragend="onDragEnd"
|
|
|
|
|
@click="openEdit(task)"
|
|
|
|
|
>
|
|
|
|
|
<div style="font-weight: 500">{{ task.title }}</div>
|
2026-08-24 08:07:23 +07:00
|
|
|
<div v-if="task.description" class="muted-note mt-1 truncate">{{ task.description }}</div>
|
2026-08-11 23:17:04 +07:00
|
|
|
<div class="mt-2 flex items-center gap-2 text-[0.8rem]">
|
|
|
|
|
<Tag :value="task.priority" :severity="prioritySeverity(task.priority)" />
|
2026-08-24 08:07:23 +07:00
|
|
|
<span class="muted-note">{{ task.assigneeName ?? 'Unassigned' }}</span>
|
|
|
|
|
<span v-if="task.dueDate" class="muted-note">{{ formatDate(task.dueDate) }}</span>
|
2026-08-11 23:17:04 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<TaskDetailDialog
|
|
|
|
|
v-model:visible="dialogVisible"
|
|
|
|
|
:task="editingTask"
|
|
|
|
|
:project-id="projectId"
|
|
|
|
|
:members="members"
|
|
|
|
|
:can-edit="auth.can('tasks', 'edit')"
|
|
|
|
|
:can-delete="auth.can('tasks', 'delete')"
|
|
|
|
|
@saved="onSaved"
|
|
|
|
|
@deleted="onDeleted"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import TaskDetailDialog from './TaskDetailDialog.vue'
|
|
|
|
|
import { getTasks, updateTask } from '../../services/modules'
|
|
|
|
|
import { getMembers } from '../../services/backend'
|
|
|
|
|
import { errorMessage } from '../../services/api'
|
|
|
|
|
import { useAuthStore } from '../../stores/auth'
|
|
|
|
|
import type { Task, TaskPriority, TaskStatus } from '../../types'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const toast = useToast()
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
|
|
|
|
const projectId = String(route.params.id)
|
|
|
|
|
const tasks = ref<Task[]>([])
|
|
|
|
|
const members = ref<{ userId: string; displayName: string }[]>([])
|
|
|
|
|
|
|
|
|
|
const draggingId = ref<string | null>(null)
|
|
|
|
|
const dragOverStatus = ref<TaskStatus | null>(null)
|
|
|
|
|
|
|
|
|
|
const dialogVisible = ref(false)
|
|
|
|
|
const editingTask = ref<Task | null>(null)
|
|
|
|
|
|
|
|
|
|
const columns = [
|
|
|
|
|
{ status: 'Todo' as TaskStatus, label: 'To Do' },
|
|
|
|
|
{ status: 'InProgress' as TaskStatus, label: 'In Progress' },
|
|
|
|
|
{ status: 'Done' as TaskStatus, label: 'Done' },
|
|
|
|
|
{ status: 'Cancelled' as TaskStatus, label: 'Cancelled' },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function tasksIn(status: TaskStatus) {
|
|
|
|
|
return tasks.value.filter((t) => t.status === status)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
try {
|
2026-08-24 08:07:23 +07:00
|
|
|
const res = await getTasks(projectId, undefined, 1, 100)
|
|
|
|
|
tasks.value = res.items
|
2026-08-11 23:17:04 +07:00
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadMembers() {
|
|
|
|
|
try {
|
2026-08-24 08:07:23 +07:00
|
|
|
const res = await getMembers(projectId, 1, 100)
|
|
|
|
|
members.value = res.items.map((m) => ({
|
2026-08-11 23:17:04 +07:00
|
|
|
userId: m.userId,
|
|
|
|
|
displayName: m.displayName,
|
|
|
|
|
}))
|
|
|
|
|
} catch {
|
|
|
|
|
members.value = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onDragStart(task: Task) {
|
|
|
|
|
draggingId.value = task.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onDragEnd() {
|
|
|
|
|
draggingId.value = null
|
|
|
|
|
dragOverStatus.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onDrop(status: TaskStatus) {
|
|
|
|
|
const id = draggingId.value
|
|
|
|
|
dragOverStatus.value = null
|
|
|
|
|
draggingId.value = null
|
|
|
|
|
if (!id) return
|
|
|
|
|
const task = tasks.value.find((t) => t.id === id)
|
|
|
|
|
if (!task || task.status === status) return
|
|
|
|
|
try {
|
|
|
|
|
await updateTask(id, {
|
|
|
|
|
title: task.title,
|
|
|
|
|
description: task.description,
|
|
|
|
|
status,
|
|
|
|
|
priority: task.priority,
|
|
|
|
|
assigneeId: task.assigneeId,
|
|
|
|
|
dueDate: task.dueDate,
|
|
|
|
|
})
|
|
|
|
|
toast.add({ severity: 'success', summary: `Moved to ${statusLabel(status)}`, life: 2000 })
|
|
|
|
|
await load()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCreate() {
|
|
|
|
|
editingTask.value = null
|
|
|
|
|
dialogVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEdit(task: Task) {
|
|
|
|
|
editingTask.value = task
|
|
|
|
|
dialogVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onSaved() {
|
|
|
|
|
dialogVisible.value = false
|
|
|
|
|
void load()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onDeleted() {
|
|
|
|
|
dialogVisible.value = false
|
|
|
|
|
void load()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function prioritySeverity(p: TaskPriority) {
|
|
|
|
|
return p === 'High' ? 'danger' : p === 'Medium' ? 'warn' : 'secondary'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusLabel(s: TaskStatus) {
|
|
|
|
|
return s.replace(/([A-Z])/g, ' $1').trim()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(v: string) {
|
|
|
|
|
return new Date(v).toLocaleDateString()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await loadMembers()
|
|
|
|
|
await load()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
2026-08-24 08:07:23 +07:00
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.task-card:hover {
|
|
|
|
|
border-color: var(--primary) !important;
|
|
|
|
|
}
|
|
|
|
|
</style>
|