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",
|
||||
"sortDefault": "Par défaut",
|
||||
"sortDeadline": "Échéance",
|
||||
"sortScheduledStart": "Date planifiée"
|
||||
"sortScheduledStart": "Date planifiée",
|
||||
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
|
||||
},
|
||||
"dashboard": {
|
||||
"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 { Project } from '~/services/dto/project'
|
||||
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 { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -71,6 +71,46 @@ const selectedTask = ref<Task | null>(null)
|
||||
// Timer
|
||||
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 {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
@@ -397,9 +437,16 @@ onMounted(async () => {
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
: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 }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
@@ -481,6 +528,16 @@ onMounted(async () => {
|
||||
</p>
|
||||
</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
|
||||
v-model="taskModalOpen"
|
||||
|
||||
Reference in New Issue
Block a user