feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer

This commit is contained in:
2026-05-19 20:10:47 +02:00
parent e6d765f7bb
commit 52b78d6bbc
2 changed files with 243 additions and 0 deletions

View File

@@ -87,10 +87,35 @@
</MalioButton> </MalioButton>
</div> </div>
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
</div>
<MalioButton
v-if="canManageWorkflows"
type="button"
icon-name="mdi:swap-horizontal"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.switchTitle')"
@click="switchModalOpen = true"
/>
</div>
</div>
<ConfirmDeleteProjectModal <ConfirmDeleteProjectModal
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<ProjectWorkflowSwitchModal
v-if="props.project"
v-model="switchModalOpen"
:project="props.project"
@switched="onWorkflowSwitched"
/>
</MalioDrawer> </MalioDrawer>
</template> </template>
@@ -122,6 +147,15 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false) const confirmDeleteOpen = ref(false)
const switchModalOpen = ref(false)
const auth = useAuthStore()
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
function onWorkflowSwitched() {
emit('saved')
isOpen.value = false
}
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])

View File

@@ -0,0 +1,209 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="close" />
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
<div class="mt-5 flex flex-col gap-5">
<MalioSelect
v-model="targetWorkflowId"
:options="targetOptions"
:label="$t('workflows.switchTargetLabel')"
empty-option-label=""
min-width="!w-full"
/>
<div v-if="targetWorkflow" class="flex flex-col gap-2">
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-neutral-500">
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
<td class="py-2 pr-3">
<span
v-if="row.source"
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
:style="{ backgroundColor: row.source.color }"
/>
{{ row.source?.label ?? $t('myTasks.backlog') }}
<span class="ml-1 text-xs text-neutral-400">
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
</span>
</td>
<td class="py-2 pr-3">
<select
v-model="row.targetId"
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
>
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
<option
v-for="s in targetWorkflow.statuses"
:key="s.id"
:value="s.id"
>
{{ s.label }}
</option>
</select>
</td>
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="$t('workflows.switchConfirm')"
button-class="w-auto px-6"
:disabled="!canConfirm || isSubmitting"
@click="confirm"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
project: Project
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'switched'): void
}>()
const workflows = ref<Workflow[]>([])
const projectTasks = ref<Task[]>([])
const targetWorkflowId = ref<number | null>(null)
const isSubmitting = ref(false)
const workflowService = useWorkflowService()
const taskService = useTaskService()
const targetOptions = computed(() =>
workflows.value
.filter(w => w.id !== props.project.workflow.id)
.map(w => ({ label: w.name, value: w.id })),
)
const targetWorkflow = computed<Workflow | null>(() =>
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
)
type Row = {
sourceId: number | null
source: TaskStatus | null
targetId: number | null
count: number
}
const mappingRows = ref<Row[]>([])
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
if (!source) return null
const sameCat = target.statuses
.filter(s => s.category === source.category)
.sort((a, b) => a.position - b.position)
return sameCat[0]?.id ?? null
}
watch(targetWorkflow, (tw) => {
if (!tw) {
mappingRows.value = []
return
}
const usedStatusIds = new Map<number | null, number>()
for (const t of projectTasks.value) {
const key = t.status?.id ?? null
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
}
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
return {
sourceId,
source,
targetId: smartPrefill(source, tw),
count,
}
})
})
const canConfirm = computed(() => {
if (!targetWorkflow.value) return false
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
})
watch(() => props.modelValue, async (open) => {
if (!open) return
targetWorkflowId.value = null
const [allWorkflows, tasks] = await Promise.all([
workflowService.getAll(),
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
])
workflows.value = allWorkflows
projectTasks.value = tasks
})
function close() {
emit('update:modelValue', false)
}
async function confirm() {
if (!targetWorkflow.value) return
isSubmitting.value = true
try {
const mapping: Record<string, number | null> = {}
for (const r of mappingRows.value) {
if (r.sourceId !== null) {
mapping[String(r.sourceId)] = r.targetId
}
}
await workflowService.switchOnProject(props.project.id, {
workflowId: targetWorkflow.value.id,
mapping,
})
emit('switched')
close()
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.15s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>