feat : add task management with kanban and backlog

Add kanban board with drag-and-drop, backlog section, task/group
drawers, DTOs, services, and i18n translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 23:39:03 +01:00
parent 0a7856b37c
commit ac11690ad4
19 changed files with 1056 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
<template>
<div
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
draggable="true"
@dragstart="onDragStart"
@dragend="onDragEnd"
@click="emit('click')"
>
<div class="flex items-start justify-between gap-2">
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<button
class="shrink-0 text-neutral-400 hover:text-primary-500"
@click.stop
>
<Icon name="mdi:play-circle-outline" size="20" />
</button>
</div>
<div class="mt-2 flex 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="type in task.types"
:key="type.id"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: type.color }"
>
{{ type.label }}
</span>
<span
v-if="task.assignee"
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-[10px] font-bold text-white"
:title="task.assignee.username"
>
{{ task.assignee.username.substring(0, 2).toUpperCase() }}
</span>
<span
v-else
class="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
>
<Icon name="mdi:account-outline" size="14" />
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
const props = defineProps<{
task: Task
}>()
const emit = defineEmits<{
(e: 'click'): void
}>()
function onDragStart(event: DragEvent) {
event.dataTransfer!.effectAllowed = 'move'
event.dataTransfer!.setData('text/plain', String(props.task.id))
;(event.target as HTMLElement).classList.add('opacity-50')
}
function onDragEnd(event: DragEvent) {
;(event.target as HTMLElement).classList.remove('opacity-50')
}
</script>

View File

@@ -0,0 +1,225 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un ticket' : 'Ajouter un ticket'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
label="Titre"
input-class="w-full"
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true"
/>
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
/>
<MalioSelect
v-model="form.statusId"
:options="statusOptions"
label="Statut"
empty-option-label="Aucun statut"
min-width="w-full"
/>
<MalioSelect
v-model="form.effortId"
:options="effortOptions"
label="Effort"
empty-option-label="Aucun effort"
min-width="w-full"
/>
<MalioSelect
v-model="form.priorityId"
:options="priorityOptions"
label="Priorité"
empty-option-label="Aucune priorité"
min-width="w-full"
/>
<MalioSelect
v-model="form.assigneeId"
:options="userOptions"
label="User"
empty-option-label="Aucun utilisateur"
min-width="w-full"
/>
<MalioSelect
v-model="form.groupId"
:options="groupOptions"
label="Groupe"
empty-option-label="Aucun groupe"
min-width="w-full"
/>
<div class="mt-4">
<p class="mb-2 text-sm font-medium text-neutral-700">Types</p>
<div class="flex flex-wrap gap-2">
<label
v-for="type in types"
:key="type.id"
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition"
:class="form.typeIds.includes(type.id)
? 'text-white'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
:style="form.typeIds.includes(type.id) ? { backgroundColor: type.color } : {}"
>
<input
type="checkbox"
class="hidden"
:value="type.id"
:checked="form.typeIds.includes(type.id)"
@change="toggleType(type.id)"
/>
{{ type.label }}
</label>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskType } from '~/services/dto/task-type'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
task: Task | null
projectId: number
statuses: TaskStatus[]
efforts: TaskEffort[]
priorities: TaskPriority[]
types: TaskType[]
groups: TaskGroup[]
users: UserData[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const form = reactive({
title: '',
description: '',
statusId: null as number | null,
effortId: null as number | null,
priorityId: null as number | null,
assigneeId: null as number | null,
groupId: null as number | null,
typeIds: [] as number[],
})
const touched = reactive({
title: false,
})
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
)
const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id }))
)
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
)
const groupOptions = computed(() =>
props.groups.map(g => ({ label: g.title, value: g.id }))
)
function toggleType(id: number) {
const idx = form.typeIds.indexOf(id)
if (idx >= 0) {
form.typeIds.splice(idx, 1)
} else {
form.typeIds.push(id)
}
}
watch(() => props.modelValue, (open) => {
if (open) {
if (props.task) {
form.title = props.task.title ?? ''
form.description = props.task.description ?? ''
form.statusId = props.task.status?.id ?? null
form.effortId = props.task.effort?.id ?? null
form.priorityId = props.task.priority?.id ?? null
form.assigneeId = props.task.assignee?.id ?? null
form.groupId = props.task.group?.id ?? null
form.typeIds = props.task.types.map(t => t.id)
} else {
form.title = ''
form.description = ''
form.statusId = null
form.effortId = null
form.priorityId = null
form.assigneeId = null
form.groupId = null
form.typeIds = []
}
touched.title = false
}
})
const { create, update } = useTaskService()
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
isSubmitting.value = true
try {
const payload: TaskWrite = {
title: form.title.trim(),
description: form.description.trim() || null,
status: form.statusId ? `/api/task_statuses/${form.statusId}` : null,
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
project: `/api/projects/${props.projectId}`,
types: form.typeIds.map(id => `/api/task_types/${id}`),
}
if (isEditing.value && props.task) {
await update(props.task.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<AppDrawer v-model="isOpen" :title="isEditing ? 'Modifier un groupe' : 'Ajouter un groupe'">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.title"
label="Titre"
input-class="w-full"
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
@blur="touched.title = true"
/>
<MalioInputTextArea
v-model="form.description"
label="Description"
:size="3"
/>
<div class="mt-4">
<ColorPicker v-model="form.color" />
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import { useTaskGroupService } from '~/services/task-groups'
const props = defineProps<{
modelValue: boolean
group: TaskGroup | null
projectId: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.group)
const isSubmitting = ref(false)
const form = reactive({
title: '',
description: '',
color: '#222783',
})
const touched = reactive({
title: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.group) {
form.title = props.group.title ?? ''
form.description = props.group.description ?? ''
form.color = props.group.color ?? '#222783'
} else {
form.title = ''
form.description = ''
form.color = '#222783'
}
touched.title = false
}
})
const { create, update } = useTaskGroupService()
async function handleSubmit() {
touched.title = true
if (!form.title.trim()) return
isSubmitting.value = true
try {
const payload: TaskGroupWrite = {
title: form.title.trim(),
description: form.description.trim() || null,
color: form.color,
project: `/api/projects/${props.projectId}`,
}
if (isEditing.value && props.group) {
await update(props.group.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>