fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
frontend/components/task/StatusPickerPopover.vue
Normal file
35
frontend/components/task/StatusPickerPopover.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
statuses: TaskStatus[]
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
pick: [status: TaskStatus]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||||
|
<div
|
||||||
|
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||||
|
:style="{ left: x + 'px', top: y + 'px' }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="s in statuses"
|
||||||
|
:key="s.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||||
|
@click="emit('pick', s)"
|
||||||
|
>
|
||||||
|
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||||
|
{{ s.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -236,7 +236,8 @@
|
|||||||
"sortBy": "Trier par",
|
"sortBy": "Trier par",
|
||||||
"sortDefault": "Par défaut",
|
"sortDefault": "Par défaut",
|
||||||
"sortDeadline": "Échéance",
|
"sortDeadline": "Échéance",
|
||||||
"sortScheduledStart": "Date planifiée"
|
"sortScheduledStart": "Date planifiée",
|
||||||
|
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { StatusCategory } from '~/services/dto/workflow'
|
import type { StatusCategory } from '~/services/dto/workflow'
|
||||||
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
@@ -71,6 +71,46 @@ const selectedTask = ref<Task | null>(null)
|
|||||||
// Timer
|
// Timer
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Drag & drop
|
||||||
|
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||||
|
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||||
|
|
||||||
|
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||||
|
const wf = task.project?.workflow
|
||||||
|
if (!wf) return []
|
||||||
|
return wf.statuses.filter(s => s.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||||
|
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||||
|
await loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||||
|
dragOverCategory.value = null
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (!task) return
|
||||||
|
const candidates = statusesForTaskCategory(task, category)
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
toast.error({ message: t('myTasks.dropRefused') })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
void applyStatus(task, candidates[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerChoice(status: TaskStatus): void {
|
||||||
|
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||||
|
pendingPicker.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function isTimerOnTask(task: Task): boolean {
|
function isTimerOnTask(task: Task): boolean {
|
||||||
const entry = timerStore.activeEntry
|
const entry = timerStore.activeEntry
|
||||||
if (!entry?.task) return false
|
if (!entry?.task) return false
|
||||||
@@ -397,9 +437,16 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-for="cat in CATEGORIES"
|
v-for="cat in CATEGORIES"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||||
|
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||||
|
@dragover.prevent="dragOverCategory = cat"
|
||||||
|
@dragleave="dragOverCategory = null"
|
||||||
|
@drop="onDrop(cat, $event)"
|
||||||
>
|
>
|
||||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
<div
|
||||||
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||||
|
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||||
|
>
|
||||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
@@ -481,6 +528,16 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
|
||||||
|
<StatusPickerPopover
|
||||||
|
v-if="pendingPicker"
|
||||||
|
:statuses="pendingPicker.statuses"
|
||||||
|
:x="pendingPicker.x"
|
||||||
|
:y="pendingPicker.y"
|
||||||
|
@pick="onPickerChoice"
|
||||||
|
@cancel="pendingPicker = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- TaskModal -->
|
<!-- TaskModal -->
|
||||||
<TaskModal
|
<TaskModal
|
||||||
v-model="taskModalOpen"
|
v-model="taskModalOpen"
|
||||||
|
|||||||
Reference in New Issue
Block a user