feat(ui) : add task list view with bulk actions, filters, and priority flag
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m25s

- Add TaskListItem component with checkbox, project color, priority flag
- Add TaskBulkActions toolbar (bulk status/user/priority/effort/group update, delete)
- Add list view toggle button in my-tasks and project pages
- Add Priorité and Effort filters to project page
- TaskCard supports showProjectColor prop (color in my-tasks, neutral in project)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-18 16:36:40 +01:00
parent ec35a1b2aa
commit 1219f3e73e
5 changed files with 460 additions and 76 deletions

View File

@@ -51,6 +51,9 @@ const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
// View toggle
const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection
const selectedTaskIds = reactive(new Set<number>())
// Modal
const taskModalOpen = ref(false)
const selectedTask = ref<Task | null>(null)
@@ -228,6 +231,52 @@ async function onSaved() {
await loadTasks()
}
function toggleTaskSelect(taskId: number) {
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId)
} else {
selectedTaskIds.add(taskId)
}
}
function toggleSelectAll(taskList: Task[]) {
if (selectedTaskIds.size === taskList.length) {
selectedTaskIds.clear()
} else {
taskList.forEach(t => selectedTaskIds.add(t.id))
}
}
async function onBulkUpdate(field: string, value: number) {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
const payload: Record<string, unknown> = {}
if (field === 'status') payload.status = `/api/task_statuses/${value}`
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
else if (field === 'group') payload.group = `/api/task_groups/${value}`
await Promise.all(ids.map(id => taskService.update(id, payload)))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkArchive() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
selectedTaskIds.clear()
await loadTasks()
}
async function onBulkDelete() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.remove(id)))
selectedTaskIds.clear()
await loadTasks()
}
onMounted(() => {
loadAll()
})
@@ -247,24 +296,16 @@ onMounted(() => {
<Icon name="mdi:plus" size="18" />
{{ $t('myTasks.createTask') }}
</button>
<div class="flex gap-1">
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewKanban')"
@click="viewMode = 'kanban'"
>
<Icon name="mdi:view-column-outline" size="18" />
</button>
<button
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
:title="$t('myTasks.viewList')"
@click="viewMode = 'list'"
>
<Icon name="mdi:view-list-outline" size="18" />
</button>
</div>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
:title="viewMode === 'list' ? $t('myTasks.viewKanban') : $t('myTasks.viewList')"
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
>
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
</div>
</div>
@@ -351,6 +392,7 @@ onMounted(() => {
v-for="task in tasksByStatus(status.id)"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
<p
@@ -379,6 +421,7 @@ onMounted(() => {
v-for="task in backlogTasks"
:key="task.id"
:task="task"
show-project-color
@click="openTaskEdit(task)"
/>
</div>
@@ -392,63 +435,31 @@ onMounted(() => {
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6">
<div
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<TaskBulkActions
:selected-count="selectedTaskIds.size"
:total-count="tasks.length"
:all-selected="tasks.length > 0 && selectedTaskIds.size === tasks.length"
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < tasks.length"
:statuses="statuses"
:users="users"
:priorities="priorities"
:efforts="efforts"
:groups="groups"
@toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@bulk-delete="onBulkDelete"
/>
<TaskListItem
v-for="task in tasks"
:key="task.id"
class="flex cursor-pointer items-center justify-between gap-2 border-b border-neutral-100 px-2 py-3 transition-colors hover:bg-neutral-50 sm:px-4"
:task="task"
show-project-color
:selected="selectedTaskIds.has(task.id)"
@click="openTaskEdit(task)"
>
<div class="min-w-0 flex-1">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.priority.color }"
>
{{ task.priority.label }}
</span>
<span
v-for="tag in task.tags"
:key="tag.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: tag.color }"
>
{{ tag.label }}
</span>
</div>
</div>
<div class="flex items-center gap-3">
<button
class="shrink-0 transition-colors"
:class="isTimerOnTask(task) ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
@click.stop="isTimerOnTask(task) ? timerStore.stop() : timerStore.startFromTask(task)"
>
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
</button>
<div class="flex items-center gap-1.5">
<Icon
v-if="task.clientTicket"
name="heroicons:user-circle"
class="h-4 w-4 text-blue-400"
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
<span
v-if="task.project && task.number"
class="text-sm font-semibold"
:style="{ color: task.project.color }"
>
{{ task.project.code }}-{{ task.number }}
</span>
<Icon
v-if="task.priority?.label === 'Haute'"
name="mdi:flag-variant"
class="h-4 w-4 text-red-600"
/>
</div>
</div>
</div>
@toggle-select="toggleTaskSelect"
/>
<p
v-if="tasks.length === 0 && !isLoading"
class="py-8 text-center text-sm text-neutral-400"

View File

@@ -11,6 +11,16 @@
<span class="hidden sm:inline">+ Ajouter un ticket</span>
<span class="sm:hidden">+ Ticket</span>
</button>
<button
class="flex items-center justify-center rounded-md border p-1.5 transition-colors"
:class="viewMode === 'list'
? 'border-primary-500 bg-primary-500 text-white'
: 'border-primary-500 text-primary-500 hover:bg-primary-50'"
title="Vue liste"
@click="viewMode = viewMode === 'kanban' ? 'list' : 'kanban'"
>
<Icon name="mdi:format-list-bulleted" size="20" />
</button>
<button
class="flex shrink-0 items-center rounded-md bg-neutral-200 px-3 py-2 text-neutral-600 hover:bg-neutral-300 sm:px-4"
title="Paramètres du projet"
@@ -58,11 +68,29 @@
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedPriorityId"
:options="priorityFilterOptions"
label="Priorité"
empty-option-label="Toutes"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedEffortId"
:options="effortFilterOptions"
label="Effort"
empty-option-label="Tous"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Kanban -->
<div class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in statuses"
:key="status.id"
@@ -100,6 +128,7 @@
<!-- Backlog -->
<div
v-if="viewMode === 'kanban'"
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-tertiary-600' : 'bg-tertiary-500'"
@dragover.prevent
@@ -118,6 +147,39 @@
</div>
</div>
<!-- List View -->
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
<TaskBulkActions
:selected-count="selectedTaskIds.size"
:total-count="filteredTasks.length"
:all-selected="filteredTasks.length > 0 && selectedTaskIds.size === filteredTasks.length"
:some-selected="selectedTaskIds.size > 0 && selectedTaskIds.size < filteredTasks.length"
:statuses="statuses"
:users="users"
:priorities="priorities"
:efforts="efforts"
:groups="groups"
@toggle-all="toggleSelectAll(filteredTasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@bulk-delete="onBulkDelete"
/>
<TaskListItem
v-for="task in filteredTasks"
:key="task.id"
:task="task"
:selected="selectedTaskIds.has(task.id)"
@click="openTaskEdit(task)"
@toggle-select="toggleTaskSelect"
/>
<p
v-if="filteredTasks.length === 0"
class="py-8 text-center text-sm text-neutral-400"
>
Aucun ticket
</p>
</div>
<TaskModal
v-model="taskDrawerOpen"
:task="selectedTask"
@@ -191,6 +253,10 @@ const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(null)
const selectedStatusId = ref<number | null>(null)
const selectedPriorityId = ref<number | null>(null)
const selectedEffortId = ref<number | null>(null)
const viewMode = ref<'kanban' | 'list'>('kanban')
const selectedTaskIds = reactive(new Set<number>())
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
const taskDrawerOpen = ref(false)
@@ -213,6 +279,14 @@ const statusFilterOptions = computed(() =>
statuses.value.map(s => ({ label: s.label, value: s.id }))
)
const priorityFilterOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id }))
)
const effortFilterOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) {
@@ -227,6 +301,12 @@ const filteredTasks = computed(() => {
if (selectedStatusId.value) {
result = result.filter(t => t.status?.id === selectedStatusId.value)
}
if (selectedPriorityId.value) {
result = result.filter(t => t.priority?.id === selectedPriorityId.value)
}
if (selectedEffortId.value) {
result = result.filter(t => t.effort?.id === selectedEffortId.value)
}
return result
})
@@ -311,6 +391,52 @@ async function onDropBacklog(event: DragEvent) {
await taskService.update(taskId, { status: null })
}
function toggleTaskSelect(taskId: number) {
if (selectedTaskIds.has(taskId)) {
selectedTaskIds.delete(taskId)
} else {
selectedTaskIds.add(taskId)
}
}
function toggleSelectAll(taskList: Task[]) {
if (selectedTaskIds.size === taskList.length) {
selectedTaskIds.clear()
} else {
taskList.forEach(t => selectedTaskIds.add(t.id))
}
}
async function onBulkUpdate(field: string, value: number) {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
const payload: Record<string, unknown> = {}
if (field === 'status') payload.status = `/api/task_statuses/${value}`
else if (field === 'assignee') payload.assignee = `/api/users/${value}`
else if (field === 'priority') payload.priority = `/api/task_priorities/${value}`
else if (field === 'effort') payload.effort = `/api/task_efforts/${value}`
else if (field === 'group') payload.group = `/api/task_groups/${value}`
await Promise.all(ids.map(id => taskService.update(id, payload)))
selectedTaskIds.clear()
await loadData()
}
async function onBulkArchive() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.update(id, { archived: true })))
selectedTaskIds.clear()
await loadData()
}
async function onBulkDelete() {
const ids = [...selectedTaskIds]
if (ids.length === 0) return
await Promise.all(ids.map(id => taskService.remove(id)))
selectedTaskIds.clear()
await loadData()
}
async function onSaved() {
await loadData()
}