feat(project-management) : extract Projects/Tasks front into Nuxt module layer
Tranche 4 of LST-65. Companion to the backend module migration.
- Move pages (my-tasks, projects, projects/[id]/{index,groups,archives}),
18 components (project + task), 10 services and 10 DTOs into
frontend/modules/project-management/ (auto-detected layer).
- Rewrite explicit ~/services/* and ~/services/dto/* imports across 38
consumers (admin tabs, mail modals, dashboard, mail page, layout) including
the time-tracking module whose DTOs referenced project/task/task-tag.
- clients.ts and shared DTOs (client, user-data) stay at the root.
- Routes /my-tasks, /projects, /projects/:id(/groups|/archives) preserved;
i18n stays global.
nuxt build passes; routes confirmed.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="codeProxy"
|
||||
label="Code"
|
||||
input-class="w-full"
|
||||
:max-length="10"
|
||||
:disabled="isEditing"
|
||||
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
|
||||
@blur="touched.code = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Titre"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div v-if="giteaRepos.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.giteaRepoFullName"
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="bookstackShelves.length" class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.bookstackShelfId"
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
v-if="project.taskCount === 0"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</MalioButton>
|
||||
</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
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<ProjectWorkflowSwitchModal
|
||||
v-if="props.project"
|
||||
v-model="switchModalOpen"
|
||||
:project="props.project"
|
||||
@switched="onWorkflowSwitched"
|
||||
/>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
project: Project | null
|
||||
clients: Client[]
|
||||
}>()
|
||||
|
||||
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.project)
|
||||
const isSubmitting = 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 giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
const giteaRepoOptions = computed(() =>
|
||||
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
|
||||
)
|
||||
|
||||
const { listShelves } = useBookStackService()
|
||||
const bookstackShelves = ref<BookStackShelf[]>([])
|
||||
|
||||
const bookstackShelfOptions = computed(() =>
|
||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#222783',
|
||||
clientId: null as number | null,
|
||||
giteaRepoFullName: null as string | null,
|
||||
bookstackShelfId: null as number | null,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
name: false,
|
||||
})
|
||||
|
||||
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
|
||||
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
|
||||
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
|
||||
// form.code en minuscules et bloquait la création.
|
||||
const codeProxy = computed({
|
||||
get: () => form.code,
|
||||
set: (value: string) => {
|
||||
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
|
||||
},
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.project) {
|
||||
form.code = props.project.code ?? ''
|
||||
form.name = props.project.name ?? ''
|
||||
form.description = props.project.description ?? ''
|
||||
form.color = props.project.color ?? '#222783'
|
||||
form.clientId = props.project.client?.id ?? null
|
||||
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
||||
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
||||
: null
|
||||
form.bookstackShelfId = props.project.bookstackShelfId ?? null
|
||||
} else {
|
||||
form.code = ''
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.color = '#222783'
|
||||
form.clientId = null
|
||||
form.giteaRepoFullName = null
|
||||
form.bookstackShelfId = null
|
||||
}
|
||||
touched.code = false
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, remove } = useProjectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
touched.code = true
|
||||
if (!form.name.trim()) return
|
||||
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProjectWrite = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || null,
|
||||
color: form.color,
|
||||
client: form.clientId ? `/api/clients/${form.clientId}` : null,
|
||||
}
|
||||
|
||||
if (form.giteaRepoFullName) {
|
||||
const [owner, repo] = form.giteaRepoFullName.split('/')
|
||||
payload.giteaOwner = owner
|
||||
payload.giteaRepo = repo
|
||||
} else {
|
||||
payload.giteaOwner = null
|
||||
payload.giteaRepo = null
|
||||
}
|
||||
|
||||
if (form.bookstackShelfId) {
|
||||
const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId)
|
||||
payload.bookstackShelfId = form.bookstackShelfId
|
||||
payload.bookstackShelfName = shelf?.name ?? null
|
||||
} else {
|
||||
payload.bookstackShelfId = null
|
||||
payload.bookstackShelfName = null
|
||||
}
|
||||
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await remove(props.project.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
confirmDeleteOpen.value = false
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchiveToggle() {
|
||||
if (!props.project) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const newArchived = !props.project.archived
|
||||
await update(props.project.id, { archived: newArchived }, {
|
||||
toastSuccessKey: newArchived ? 'projects.archived' : 'projects.unarchived',
|
||||
})
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
giteaRepos.value = await listRepositories()
|
||||
} catch {
|
||||
// Gitea not configured, ignore
|
||||
}
|
||||
try {
|
||||
bookstackShelves.value = await listShelves()
|
||||
} catch {
|
||||
// BookStack not configured, ignore
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-3"
|
||||
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||
@click="showArchived = !showArchived"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="!showArchived"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un groupe"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
empty-message="Aucun groupe trouvé."
|
||||
:deletable="!showArchived"
|
||||
@row-click="openEdit"
|
||||
@delete="(item) => handleDelete(item.id)"
|
||||
>
|
||||
<template #cell-color="{ item }">
|
||||
<span
|
||||
class="inline-block h-6 w-6 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ stripRichText(item.description) || '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButton
|
||||
v-if="!showArchived && canArchiveGroup(item)"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleArchive(item)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchived"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleUnarchive(item)"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<TaskGroupDrawer
|
||||
v-model="drawerOpen"
|
||||
:group="selectedItem"
|
||||
:project-id="projectId"
|
||||
:tasks="[...activeTasks, ...archivedTasks]"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'title', label: 'Titre', primary: true },
|
||||
{ key: 'color', label: 'Couleur' },
|
||||
{ key: 'description', label: 'Description', class: 'max-w-xs truncate text-neutral-700' },
|
||||
]
|
||||
|
||||
const groupService = useTaskGroupService()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const allGroups = ref<TaskGroup[]>([])
|
||||
const activeTasks = ref<Task[]>([])
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<TaskGroup | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
const items = computed(() =>
|
||||
allGroups.value.filter(g => showArchived.value ? g.archived : !g.archived)
|
||||
)
|
||||
|
||||
function canArchiveGroup(group: TaskGroup): boolean {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
if (groupTasks.length === 0) return false
|
||||
return groupTasks.every(t => t.status?.isFinal === true)
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [g, t, at] = await Promise.all([
|
||||
groupService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId),
|
||||
taskService.getByProject(props.projectId, true),
|
||||
])
|
||||
allGroups.value = g
|
||||
activeTasks.value = t
|
||||
archivedTasks.value = at
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: TaskGroup) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await groupService.remove(id)
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleArchive(group: TaskGroup) {
|
||||
const groupTasks = activeTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: true })))
|
||||
await groupService.update(group.id, { archived: true })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function handleUnarchive(group: TaskGroup) {
|
||||
const groupTasks = archivedTasks.value.filter(t => t.group?.id === group.id)
|
||||
await Promise.all(groupTasks.map(t => taskService.update(t.id, { archived: false })))
|
||||
await groupService.update(group.id, { archived: false })
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
emit('updated')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
})
|
||||
</script>
|
||||
@@ -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="—"
|
||||
group-class="!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 '~/modules/project-management/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskService } from '~/modules/project-management/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>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/modules/project-management/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>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="mt-5">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<MalioInputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('bookstack.links.searchPlaceholder')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Dropdown results -->
|
||||
<div
|
||||
v-if="searchResults.length > 0"
|
||||
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="result in searchResults"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="handleAdd(result)"
|
||||
>
|
||||
<Icon
|
||||
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<span class="truncate">{{ result.name }}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Linked documents -->
|
||||
<div v-if="links.length > 0" class="mt-3 space-y-1">
|
||||
<div
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
|
||||
>
|
||||
<Icon
|
||||
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
|
||||
size="16"
|
||||
class="shrink-0 text-neutral-400"
|
||||
/>
|
||||
<a
|
||||
:href="link.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate text-primary-500 hover:underline"
|
||||
>
|
||||
{{ link.title }}
|
||||
</a>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Supprimer le lien"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
@click="handleRemove(link.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('bookstack.links.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const { getLinks, addLink, removeLink, search } = useBookStackService()
|
||||
|
||||
const links = ref<BookStackLink[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<BookStackSearchResult[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSearching = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchQuery, (query) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
hasSearched.value = false
|
||||
searchResults.value = []
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(async () => {
|
||||
isSearching.value = true
|
||||
try {
|
||||
searchResults.value = await search(props.taskId, query.trim())
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
hasSearched.value = true
|
||||
}
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function handleAdd(result: BookStackSearchResult) {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
hasSearched.value = false
|
||||
|
||||
// Check if already linked
|
||||
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await addLink(props.taskId, {
|
||||
bookstackId: result.id,
|
||||
bookstackType: result.type,
|
||||
title: result.name,
|
||||
url: result.url,
|
||||
})
|
||||
links.value.unshift(created)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(linkId: number) {
|
||||
try {
|
||||
await removeLink(props.taskId, linkId)
|
||||
links.value = links.value.filter(l => l.id !== linkId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
links.value = await getLinks(props.taskId)
|
||||
} catch {
|
||||
// Error handled by useApi toast
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 rounded-[10px] bg-white px-3 py-2 shadow-sm">
|
||||
<!-- Select all checkbox -->
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="allSelected ? 'border-primary-500 bg-primary-500' : someSelected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click="emit('toggle-all')"
|
||||
>
|
||||
<Icon v-if="allSelected" name="mdi:check" size="12" class="text-white" />
|
||||
<Icon v-else-if="someSelected" name="mdi:minus" size="12" class="text-white" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-neutral-500">
|
||||
{{ selectedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status (scoped to single project's workflow) -->
|
||||
<MalioSelect
|
||||
v-if="!isMultiProject"
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
|
||||
title="Sélection multi-projets — le statut dépend du workflow de chaque projet"
|
||||
>
|
||||
Status —
|
||||
</span>
|
||||
<!-- Bulk user -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
/>
|
||||
<!-- Bulk priority -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
/>
|
||||
<!-- Bulk effort -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
/>
|
||||
<!-- Bulk group -->
|
||||
<MalioSelect
|
||||
v-if="groupOptions.length > 0"
|
||||
:model-value="null"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
/>
|
||||
|
||||
<!-- Archive (only when current filter targets a final status) -->
|
||||
<MalioButtonIcon
|
||||
v-if="canArchive"
|
||||
icon="mdi:archive-outline"
|
||||
aria-label="Archiver"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="self-end text-neutral-500 hover:bg-primary-50 hover:text-primary-500"
|
||||
@click="emit('bulk-archive')"
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
|
||||
@click="emit('bulk-delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
statuses: TaskStatus[]
|
||||
users: UserData[]
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
selectedTasks?: Task[]
|
||||
projects?: Project[]
|
||||
canArchive?: boolean
|
||||
}>(), {
|
||||
selectedTasks: () => [],
|
||||
projects: () => [],
|
||||
canArchive: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
(e: 'bulk-update', field: string, value: number): void
|
||||
(e: 'bulk-archive'): void
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const distinctProjectIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const t of props.selectedTasks) {
|
||||
if (t.project) ids.add(t.project.id)
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
|
||||
|
||||
const statusOptions = computed<{ label: string, value: number }[]>(() => {
|
||||
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
|
||||
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
|
||||
const projectId = [...distinctProjectIds.value][0]
|
||||
const project = props.projects.find(p => p.id === projectId)
|
||||
if (project?.workflow?.statuses) {
|
||||
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
}
|
||||
}
|
||||
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
|
||||
return props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
})
|
||||
|
||||
const userOptions = computed(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
props.priorities.map(p => ({ label: p.label, value: p.id })),
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id })),
|
||||
)
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<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">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="showStatusBadge && task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.status.color }"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<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>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="14"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="ml-auto h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<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 '~/modules/project-management/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
showStatusBadge?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
showStatusBadge: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
|
||||
function onPlay() {
|
||||
timerStore.startFromTask(props.task)
|
||||
}
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="mt-3">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">
|
||||
{{ $t('taskDocuments.title') }} ({{ documents.length }})
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
<!-- Thumbnail or icon -->
|
||||
<div class="relative h-10 w-10 shrink-0">
|
||||
<div class="flex h-10 w-10 items-center justify-center overflow-hidden rounded">
|
||||
<img
|
||||
v-if="isImage(doc.mimeType)"
|
||||
:src="getDownloadUrl(doc.id)"
|
||||
:alt="doc.originalName"
|
||||
class="h-10 w-10 object-cover"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
:name="getIconForMime(doc.mimeType)"
|
||||
class="h-6 w-6 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<!-- Pastille : document lié depuis le partage SMB -->
|
||||
<span
|
||||
v-if="doc.sharePath"
|
||||
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary-500 ring-2 ring-white"
|
||||
:title="$t('taskDocuments.shareLinkBadge')"
|
||||
>
|
||||
<Icon name="heroicons:link" class="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
||||
<p class="text-xs text-neutral-400">
|
||||
<span v-if="doc.sharePath" class="font-medium text-primary-500">{{ $t('taskDocuments.shareLinkLabel') }}</span>
|
||||
<span v-if="doc.sharePath"> · </span>{{ formatFileSize(doc.size) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<MalioButtonIcon
|
||||
v-if="isAdmin"
|
||||
icon="heroicons:x-mark"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
defineProps<{
|
||||
documents: TaskDocument[]
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
preview: [doc: TaskDocument]
|
||||
delete: [doc: TaskDocument]
|
||||
}>()
|
||||
|
||||
const { getDownloadUrl } = useTaskDocumentService()
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getIconForMime(mimeType: string): string {
|
||||
if (mimeType === 'text/markdown') return 'mdi:language-markdown'
|
||||
if (mimeType === 'application/pdf') return 'heroicons:document-text'
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
|
||||
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
|
||||
return 'heroicons:paper-clip'
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" appear>
|
||||
<div
|
||||
v-if="document"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
|
||||
@click.self="$emit('close')"
|
||||
@keydown.escape="$emit('close')"
|
||||
@keydown.left="$emit('prev')"
|
||||
@keydown.right="$emit('next')"
|
||||
tabindex="0"
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<MalioButtonIcon
|
||||
v-if="hasPrev"
|
||||
icon="heroicons:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('prev')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
v-if="hasNext"
|
||||
icon="heroicons:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="$emit('next')"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||
<!-- Image preview -->
|
||||
<img
|
||||
v-if="isImage"
|
||||
:src="downloadUrl"
|
||||
:alt="document.originalName"
|
||||
class="max-h-[85vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
|
||||
<!-- PDF preview -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="downloadUrl"
|
||||
class="h-[85vh] w-[80vw] rounded-lg bg-white"
|
||||
/>
|
||||
|
||||
<!-- Text / Markdown preview -->
|
||||
<div
|
||||
v-else-if="isText"
|
||||
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
|
||||
<p class="truncate text-sm font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200"
|
||||
@click="copyContent"
|
||||
>
|
||||
<Icon
|
||||
:name="copied ? 'heroicons:check' : 'mdi:content-copy'"
|
||||
class="h-4 w-4"
|
||||
:class="copied ? 'text-green-600' : ''"
|
||||
/>
|
||||
{{ copied ? $t('taskDocuments.copied') : $t('taskDocuments.copy') }}
|
||||
</button>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto p-4">
|
||||
<div v-if="loadingText" class="flex justify-center py-10">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<pre
|
||||
v-else
|
||||
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
|
||||
>{{ textContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic file -->
|
||||
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
|
||||
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
|
||||
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
|
||||
<p class="text-sm text-neutral-400">{{ formatFileSize(document.size) }}</p>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
download
|
||||
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
{{ $t('taskDocuments.download') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- File name footer -->
|
||||
<p v-if="!isText" class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const props = defineProps<{
|
||||
document: TaskDocument | null
|
||||
hasPrev: boolean
|
||||
hasNext: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
prev: []
|
||||
next: []
|
||||
}>()
|
||||
|
||||
const overlayRef = ref<HTMLElement | null>(null)
|
||||
const textContent = ref('')
|
||||
const loadingText = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const { getDownloadUrl, getContent } = useTaskDocumentService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const TEXT_MIME_TYPES = ['text/markdown', 'text/plain', 'text/csv', 'application/json', 'application/xml', 'text/xml']
|
||||
|
||||
function isTextDocument(doc: TaskDocument | null): boolean {
|
||||
if (!doc) return false
|
||||
if (TEXT_MIME_TYPES.includes(doc.mimeType)) return true
|
||||
return /\.(md|markdown|txt|csv|json|xml)$/i.test(doc.originalName)
|
||||
}
|
||||
|
||||
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
|
||||
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
|
||||
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
|
||||
const isText = computed(() => isTextDocument(props.document))
|
||||
|
||||
async function copyContent() {
|
||||
if (await copyToClipboard(textContent.value)) {
|
||||
copied.value = true
|
||||
useToast().success(t('taskDocuments.copied'))
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Focus overlay for keyboard events, and load text content for text/markdown documents
|
||||
watch(() => props.document, async (doc) => {
|
||||
textContent.value = ''
|
||||
copied.value = false
|
||||
if (!doc) return
|
||||
|
||||
nextTick(() => overlayRef.value?.focus())
|
||||
|
||||
if (isTextDocument(doc)) {
|
||||
loadingText.value = true
|
||||
try {
|
||||
textContent.value = await getContent(doc.id)
|
||||
} catch {
|
||||
textContent.value = ''
|
||||
} finally {
|
||||
loadingText.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="close" />
|
||||
<div class="relative z-10 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-white shadow-xl">
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.linkShareTitle') }}</h3>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:x-mark"
|
||||
:aria-label="$t('common.cancel')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-neutral-700"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="flex flex-wrap items-center gap-1 border-b border-neutral-100 px-6 py-2 text-sm text-neutral-500">
|
||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span>/</span>
|
||||
<button class="hover:text-primary-500" @click="openPath(crumb.path)">{{ crumb.name }}</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="min-h-[12rem] flex-1 overflow-auto px-2 py-2">
|
||||
<div v-if="loading" class="flex justify-center py-12">
|
||||
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
|
||||
</div>
|
||||
<p v-else-if="error" class="px-4 py-12 text-center text-sm text-red-600">{{ error }}</p>
|
||||
<p v-else-if="entries.length === 0" class="px-4 py-12 text-center text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
|
||||
<ul v-else class="text-sm">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.path"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-3 py-2 hover:bg-neutral-50"
|
||||
:class="{ 'opacity-60': linking }"
|
||||
@click="onEntryClick(entry)"
|
||||
>
|
||||
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
|
||||
<span class="flex-1 truncate">{{ entry.name }}</span>
|
||||
<span class="shrink-0 text-xs text-neutral-400">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="border-t border-neutral-100 px-6 py-3 text-xs text-neutral-400">{{ $t('taskDocuments.linkShareHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'linked'): void
|
||||
}>()
|
||||
|
||||
const { browse } = useShareService()
|
||||
const { linkShare } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentPath = ref('')
|
||||
const breadcrumb = ref<Breadcrumb[]>([])
|
||||
const entries = ref<FileEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const linking = ref(false)
|
||||
|
||||
async function load(path: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await browse(path)
|
||||
currentPath.value = result.path
|
||||
breadcrumb.value = result.breadcrumb
|
||||
entries.value = result.entries
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message ?? t('sharedFiles.previewError')
|
||||
entries.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPath(path: string) {
|
||||
load(path)
|
||||
}
|
||||
|
||||
async function onEntryClick(entry: FileEntry) {
|
||||
if (linking.value) return
|
||||
if (entry.isDir) {
|
||||
load(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
linking.value = true
|
||||
try {
|
||||
await linkShare(props.taskId, entry.path)
|
||||
toast.success({ title: '', message: t('taskDocuments.linkShareSuccess') })
|
||||
emit('linked')
|
||||
close()
|
||||
} catch {
|
||||
toast.error({ title: 'Erreur', message: t('taskDocuments.linkShareError') })
|
||||
} finally {
|
||||
linking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function iconForMime(mime: string): string {
|
||||
if (mime.startsWith('image/')) return 'mdi:file-image-outline'
|
||||
if (mime === 'application/pdf') return 'mdi:file-pdf-box'
|
||||
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'mdi:file-word-outline'
|
||||
if (mime.includes('spreadsheetml') || mime === 'application/vnd.ms-excel') return 'mdi:file-excel-outline'
|
||||
if (mime.startsWith('text/')) return 'mdi:file-document-outline'
|
||||
return 'mdi:file-outline'
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
entries.value = []
|
||||
load('')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
|
||||
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
|
||||
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
|
||||
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
|
||||
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
|
||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="[
|
||||
upload.error ? 'bg-red-500' : upload.uploading ? 'animate-pulse bg-blue-400' : 'bg-green-500',
|
||||
]"
|
||||
:style="{ width: upload.uploading ? '70%' : `${upload.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="upload.error"
|
||||
name="heroicons:exclamation-circle"
|
||||
class="h-5 w-5 shrink-0 text-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
type UploadState = {
|
||||
name: string
|
||||
progress: number
|
||||
uploading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
const uploads = ref<UploadState[]>([])
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files?.length) {
|
||||
processFiles(Array.from(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: File[]) {
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > maxSize) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.maxSizeError'),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const state: UploadState = reactive({
|
||||
name: file.name,
|
||||
progress: 30,
|
||||
uploading: true,
|
||||
error: false,
|
||||
})
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
state.progress = 100
|
||||
} catch {
|
||||
state.uploading = false
|
||||
state.error = true
|
||||
state.progress = 100
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: t('taskDocuments.uploadError'),
|
||||
})
|
||||
}
|
||||
|
||||
emit('uploaded')
|
||||
}
|
||||
|
||||
// Clean up completed uploads after a delay
|
||||
setTimeout(() => {
|
||||
uploads.value = uploads.value.filter(u => u.error)
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskEffort | null
|
||||
}>()
|
||||
|
||||
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.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
} else {
|
||||
form.label = ''
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskEffortService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskEffortWrite = {
|
||||
label: form.label.trim(),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50">
|
||||
<!-- Header with tabs -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 bg-neutral-100/60 px-4 py-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'branches'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'branches'"
|
||||
>
|
||||
<Icon name="mdi:source-branch" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.title') }}
|
||||
<span
|
||||
v-if="branches.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-neutral-200 px-1 text-[10px] font-bold text-neutral-600"
|
||||
>{{ branches.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
:class="activeTab === 'prs'
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = 'prs'"
|
||||
>
|
||||
<Icon name="mdi:source-pull" size="14" class="mr-1 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.pr.title') }}
|
||||
<span
|
||||
v-if="pullRequests.length"
|
||||
class="ml-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold"
|
||||
:class="hasOpenPr ? 'bg-green-100 text-green-700' : 'bg-neutral-200 text-neutral-600'"
|
||||
>{{ pullRequests.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<MalioButtonIcon
|
||||
v-if="activeTab === 'branches'"
|
||||
icon="mdi:content-copy"
|
||||
:aria-label="$t('gitea.branch.copy')"
|
||||
variant="ghost"
|
||||
icon-size="14"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="activeTab === 'branches'"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||
:label="$t('gitea.branch.create')"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="px-4 py-3">
|
||||
<p class="text-xs text-red-500">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Create branch form (inline) -->
|
||||
<Transition name="slide-down">
|
||||
<div v-if="showCreateForm && activeTab === 'branches'" class="relative z-20 border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<div class="grid grid-cols-[1fr_1fr_auto] items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||
:disabled="isCreating"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||
{{ branchPreview }}
|
||||
</code>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Content area with scroll -->
|
||||
<div class="max-h-64 overflow-y-auto overscroll-contain">
|
||||
<!-- Loading -->
|
||||
<div v-if="(activeTab === 'branches' && isLoading) || (activeTab === 'prs' && isLoadingPrs)" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="20" class="animate-spin text-neutral-300" />
|
||||
</div>
|
||||
|
||||
<!-- BRANCHES TAB -->
|
||||
<template v-if="activeTab === 'branches' && !isLoading">
|
||||
<div v-if="branches.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="branch in branches"
|
||||
:key="branch.name"
|
||||
class="group"
|
||||
>
|
||||
<!-- Branch header (clickable to expand) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-white"
|
||||
@click="toggleBranch(branch.name)"
|
||||
>
|
||||
<Icon
|
||||
name="mdi:chevron-right"
|
||||
size="14"
|
||||
class="shrink-0 text-neutral-400 transition-transform"
|
||||
:class="{ 'rotate-90': expandedBranches.has(branch.name) }"
|
||||
/>
|
||||
<Icon name="mdi:source-branch" size="14" class="shrink-0 text-primary-500" />
|
||||
<span class="min-w-0 truncate text-xs font-medium text-primary-600">
|
||||
{{ branch.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="branch.commits.length"
|
||||
class="ml-auto shrink-0 rounded bg-neutral-200/60 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500"
|
||||
>
|
||||
{{ branch.commits.length }} commit{{ branch.commits.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<a
|
||||
:href="branchUrl(branch.name)"
|
||||
target="_blank"
|
||||
class="shrink-0 text-neutral-400 opacity-0 transition-opacity hover:text-primary-500 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<Icon name="mdi:open-in-new" size="12" />
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<!-- Commits (collapsible) -->
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedBranches.has(branch.name) && branch.commits.length" class="border-t border-neutral-100 bg-white">
|
||||
<div
|
||||
v-for="(commit, idx) in branch.commits.slice(0, 10)"
|
||||
:key="commit.sha"
|
||||
class="flex items-center gap-2 px-4 py-1.5"
|
||||
:class="idx !== Math.min(branch.commits.length, 10) - 1 ? 'border-b border-neutral-50' : ''"
|
||||
>
|
||||
<span class="shrink-0 pl-5 font-mono text-[10px] text-primary-400">{{ commit.sha.slice(0, 7) }}</span>
|
||||
<span class="min-w-0 truncate text-[11px] text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-neutral-400">{{ commit.author }}</span>
|
||||
<span class="shrink-0 text-[10px] text-neutral-300">{{ formatDate(commit.date) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="branch.commits.length > 10"
|
||||
class="border-t border-neutral-50 px-4 py-1.5 text-center text-[10px] text-neutral-400"
|
||||
>
|
||||
+{{ branch.commits.length - 10 }} commits
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.branch.noBranches') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- PULL REQUESTS TAB -->
|
||||
<template v-if="activeTab === 'prs' && !isLoadingPrs">
|
||||
<div v-if="pullRequests.length" class="divide-y divide-neutral-100">
|
||||
<div
|
||||
v-for="pr in pullRequests"
|
||||
:key="pr.number"
|
||||
class="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-white"
|
||||
>
|
||||
<!-- Status pill -->
|
||||
<span
|
||||
class="mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
|
||||
:class="prStatusClass(pr)"
|
||||
>
|
||||
{{ prStatusLabel(pr) }}
|
||||
</span>
|
||||
|
||||
<!-- PR content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<a
|
||||
:href="pr.url"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-800 hover:text-primary-500 hover:underline"
|
||||
>
|
||||
<span class="text-neutral-400">#{{ pr.number }}</span>
|
||||
{{ pr.title }}
|
||||
</a>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span class="text-[10px] text-neutral-400">{{ pr.author }}</span>
|
||||
<span v-if="pr.headBranch" class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500">
|
||||
{{ pr.headBranch }}
|
||||
</span>
|
||||
<!-- CI statuses -->
|
||||
<template v-if="pr.ciStatuses.length">
|
||||
<a
|
||||
v-for="ci in pr.ciStatuses"
|
||||
:key="ci.context"
|
||||
:href="ci.target_url"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium transition-opacity hover:opacity-80"
|
||||
:class="ciStatusClass(ci.status)"
|
||||
>
|
||||
<Icon :name="ciStatusIcon(ci.status)" size="10" />
|
||||
{{ ci.context }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="branches.length && !error" class="py-6 text-center text-xs text-neutral-400">
|
||||
{{ $t('gitea.pr.noPrs') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
giteaUrl: string
|
||||
}>()
|
||||
|
||||
const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()
|
||||
|
||||
const activeTab = ref<'branches' | 'prs'>('branches')
|
||||
const branches = ref<GiteaBranch[]>([])
|
||||
const pullRequests = ref<GiteaPullRequest[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isLoadingPrs = ref(true)
|
||||
const isCreating = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateForm = ref(false)
|
||||
const expandedBranches = ref(new Set<string>())
|
||||
|
||||
const branchForm = reactive({
|
||||
type: 'feature',
|
||||
baseBranch: 'develop',
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('gitea.branch.types.feature'), value: 'feature' },
|
||||
{ label: t('gitea.branch.types.fix'), value: 'fix' },
|
||||
{ label: t('gitea.branch.types.refactor'), value: 'refactor' },
|
||||
{ label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
|
||||
{ label: t('gitea.branch.types.chore'), value: 'chore' },
|
||||
]
|
||||
|
||||
const hasOpenPr = computed(() => pullRequests.value.some(pr => pr.state === 'open' && !pr.merged))
|
||||
|
||||
const branchPreview = computed(() => {
|
||||
if (!props.task.project?.code || !props.task.number) return ''
|
||||
const slug = props.task.title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 50)
|
||||
return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
|
||||
})
|
||||
|
||||
function toggleBranch(name: string) {
|
||||
if (expandedBranches.value.has(name)) {
|
||||
expandedBranches.value.delete(name)
|
||||
} else {
|
||||
expandedBranches.value.add(name)
|
||||
}
|
||||
}
|
||||
|
||||
function branchUrl(name: string): string {
|
||||
const project = props.task.project
|
||||
if (!project?.giteaOwner || !project?.giteaRepo) return '#'
|
||||
return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function commitFirstLine(message: string): string {
|
||||
return message.split('\n')[0]
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) return "aujourd'hui"
|
||||
if (diffDays === 1) return 'hier'
|
||||
if (diffDays < 7) return `il y a ${diffDays}j`
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function prStatusClass(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return 'bg-purple-500'
|
||||
if (pr.state === 'open') return 'bg-green-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function prStatusLabel(pr: GiteaPullRequest): string {
|
||||
if (pr.merged) return t('gitea.pr.merged')
|
||||
if (pr.state === 'open') return t('gitea.pr.open')
|
||||
return t('gitea.pr.closed')
|
||||
}
|
||||
|
||||
function ciStatusClass(status: string): string {
|
||||
if (status === 'success') return 'bg-green-100 text-green-700'
|
||||
if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
|
||||
return 'bg-yellow-100 text-yellow-700'
|
||||
}
|
||||
|
||||
function ciStatusIcon(status: string): string {
|
||||
if (status === 'success') return 'mdi:check-circle'
|
||||
if (status === 'failure' || status === 'error') return 'mdi:close-circle'
|
||||
return 'mdi:clock-outline'
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.task.id) return
|
||||
|
||||
isLoading.value = true
|
||||
isLoadingPrs.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
branches.value = await listBranches(props.task.id)
|
||||
// Auto-expand first branch
|
||||
if (branches.value.length === 1) {
|
||||
expandedBranches.value.add(branches.value[0].name)
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
pullRequests.value = await listPullRequests(props.task.id)
|
||||
} catch {
|
||||
// PR errors don't block branch display
|
||||
} finally {
|
||||
isLoadingPrs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
isCreating.value = true
|
||||
try {
|
||||
await createBranch(props.task.id, {
|
||||
type: branchForm.type,
|
||||
baseBranch: branchForm.baseBranch,
|
||||
})
|
||||
showCreateForm.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
const result = await getBranchName(props.task.id, branchForm.type)
|
||||
await copyToClipboard(result.name)
|
||||
const { success } = useToast()
|
||||
success(t('gitea.branch.copied'))
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-down-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
|
||||
</template>
|
||||
<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"
|
||||
/>
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditing && !canArchive && !canUnarchive && nonFinalTasksCount > 0"
|
||||
class="mt-4 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('archive.groupNonFinalTasks', { count: nonFinalTasksCount }) }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
group: TaskGroup | null
|
||||
projectId: number
|
||||
tasks?: Task[]
|
||||
}>()
|
||||
|
||||
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()
|
||||
const taskService = useTaskService()
|
||||
|
||||
const groupTasks = computed(() =>
|
||||
(props.tasks ?? []).filter(t => t.group?.id === props.group?.id)
|
||||
)
|
||||
|
||||
const nonFinalTasksCount = computed(() =>
|
||||
groupTasks.value.filter(t => t.status?.isFinal !== true).length
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.group || props.group.archived) return false
|
||||
if (groupTasks.value.length === 0) return false
|
||||
return nonFinalTasksCount.value === 0
|
||||
})
|
||||
|
||||
const canUnarchive = computed(() => {
|
||||
return isEditing.value && !!props.group?.archived
|
||||
})
|
||||
|
||||
async function handleArchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: true })))
|
||||
await update(props.group.id, { archived: true })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive() {
|
||||
if (!props.group) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await Promise.all(groupTasks.value.map(t => taskService.update(t.id, { archived: false })))
|
||||
await update(props.group.id, { archived: false })
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex cursor-pointer items-stretch gap-3 rounded-[10px] bg-white px-3 py-2.5 transition-colors hover:shadow-sm sm:px-4"
|
||||
:class="selected ? 'ring-2 ring-primary-500' : ''"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Row 1: checkbox + code + flag -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded border-2 transition-colors"
|
||||
:class="selected ? 'border-primary-500 bg-primary-500' : 'border-neutral-300 hover:border-primary-400'"
|
||||
@click.stop="emit('toggle-select', task.id)"
|
||||
>
|
||||
<Icon v-if="selected" name="mdi:check" size="12" class="text-white" />
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-xs font-semibold"
|
||||
:class="showProjectColor ? '' : 'text-neutral-400'"
|
||||
:style="showProjectColor && task.project.color ? { color: task.project.color } : {}"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
<Icon
|
||||
v-if="task.priority?.label === 'Haute'"
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
</div>
|
||||
<!-- Row 2: title -->
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<!-- Row 3: tags + status + deadline/calendar/recurrence -->
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-for="tag in task.tags"
|
||||
:key="tag.id"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="text-xs font-semibold uppercase text-neutral-400"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-semibold uppercase text-neutral-300">
|
||||
Backlog
|
||||
</span>
|
||||
<!-- Deadline badge -->
|
||||
<span
|
||||
v-if="task.deadline"
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
:style="{ backgroundColor: deadlineColor }"
|
||||
:title="task.deadline"
|
||||
>
|
||||
{{ formatDeadline(task.deadline) }}
|
||||
</span>
|
||||
<!-- Calendar sync icon -->
|
||||
<Icon
|
||||
v-if="task.syncToCalendar"
|
||||
:name="task.calendarSyncError ? 'mdi:alert-circle' : 'mdi:calendar-check'"
|
||||
:class="task.calendarSyncError ? 'text-red-500' : 'text-green-500'"
|
||||
size="13"
|
||||
/>
|
||||
<!-- Recurrence icon -->
|
||||
<Icon
|
||||
v-if="task.recurrence"
|
||||
name="mdi:repeat"
|
||||
class="text-blue-500"
|
||||
size="13"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
selected?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
selected: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'toggle-select', taskId: number): void
|
||||
}>()
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const deadlineColor = computed(() => {
|
||||
if (!props.task.deadline) return ''
|
||||
const daysLeft = (new Date(props.task.deadline).getTime() - Date.now()) / 86400000
|
||||
if (daysLeft < 0) return '#DC2626'
|
||||
if (daysLeft < 2) return '#F59E0B'
|
||||
return '#9CA3AF'
|
||||
})
|
||||
|
||||
function formatDeadline(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const isTimerOnTask = computed(() => {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = props.task['@id'] ?? props.task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${props.task.id}`
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskPriority | null
|
||||
}>()
|
||||
|
||||
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.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
form.color = props.item.color ?? '#222783'
|
||||
} else {
|
||||
form.label = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskPriorityService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskPriorityWrite = {
|
||||
label: form.label.trim(),
|
||||
color: form.color,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: TaskTag | null
|
||||
}>()
|
||||
|
||||
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.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
label: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.label = props.item.label ?? ''
|
||||
form.color = props.item.color ?? '#222783'
|
||||
} else {
|
||||
form.label = ''
|
||||
form.color = '#222783'
|
||||
}
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useTaskTagService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.label = true
|
||||
if (!form.label.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: TaskTagWrite = {
|
||||
label: form.label.trim(),
|
||||
color: form.color,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.item) {
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,558 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { StatusCategory } from '~/modules/project-management/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/modules/project-management/services/dto/workflow'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('myTasks.title') })
|
||||
|
||||
const taskService = useTaskService()
|
||||
const statusService = useTaskStatusService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const statuses = ref<TaskStatus[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const selectedTagId = ref<number | null>(null)
|
||||
const selectedPriorityId = ref<number | null>(null)
|
||||
const selectedEffortId = ref<number | null>(null)
|
||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||
|
||||
// Sort
|
||||
const SORT_DEADLINE = 1
|
||||
const SORT_SCHEDULED = 2
|
||||
const sortById = ref<number | null>(null)
|
||||
|
||||
// View toggle
|
||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||
|
||||
// Bulk selection
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
|
||||
|
||||
// Modal
|
||||
const taskModalOpen = ref(false)
|
||||
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[] {
|
||||
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
|
||||
const project = projects.value.find(p => p.id === task.project?.id)
|
||||
const wf = project?.workflow
|
||||
if (!wf || typeof wf === 'string') 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
|
||||
const entryTaskId = typeof entry.task === 'string'
|
||||
? entry.task
|
||||
: (entry.task['@id'] ?? entry.task.id)
|
||||
const taskId = task['@id'] ?? task.id
|
||||
return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}`
|
||||
}
|
||||
|
||||
// Filter options
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
let g = groups.value.filter(g => !g.archived)
|
||||
if (selectedProjectId.value) {
|
||||
g = g.filter(g => g.project?.id === selectedProjectId.value)
|
||||
}
|
||||
return g.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id }))
|
||||
)
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
efforts.value.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
const sortOptions = computed(() => [
|
||||
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||
])
|
||||
|
||||
// Kanban helpers (grouped by canonical status category)
|
||||
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
||||
|
||||
function tasksByCategory(category: StatusCategory): Task[] {
|
||||
return tasks.value.filter(t => t.status?.category === category)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
// Data loading
|
||||
async function loadReferenceData() {
|
||||
const [s, e, pr, tg, g, u, p] = await Promise.all([
|
||||
statusService.getAll(),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getAll(),
|
||||
userService.getAll(),
|
||||
projectService.getAll(),
|
||||
])
|
||||
statuses.value = s
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = tg
|
||||
groups.value = g
|
||||
users.value = u
|
||||
projects.value = p
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (selectedProjectId.value) {
|
||||
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||
}
|
||||
if (selectedGroupId.value) {
|
||||
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||
}
|
||||
if (selectedPriorityId.value) {
|
||||
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||
}
|
||||
if (selectedEffortId.value) {
|
||||
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||
}
|
||||
if (sortById.value === SORT_DEADLINE) {
|
||||
baseParams['order[deadline]'] = 'asc'
|
||||
} else if (sortById.value === SORT_SCHEDULED) {
|
||||
baseParams['order[scheduledStart]'] = 'asc'
|
||||
}
|
||||
|
||||
if (selectedAssigneeId.value) {
|
||||
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||
])
|
||||
const map = new Map<number, Task>()
|
||||
for (const t of assigneeTasks) map.set(t.id, t)
|
||||
for (const t of collabTasks) map.set(t.id, t)
|
||||
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||
} else {
|
||||
tasks.value = await taskService.getFiltered(baseParams)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await Promise.all([loadReferenceData(), loadTasks()])
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch filters and sort to reload tasks
|
||||
watch(
|
||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||
() => { loadTasks() },
|
||||
)
|
||||
|
||||
// Reset group when project changes
|
||||
watch(selectedProjectId, () => {
|
||||
selectedGroupId.value = null
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// Modal
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskModalOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskModalOpen.value = true
|
||||
if (task.project?.code && task.number) {
|
||||
router.replace({ query: { task: `${task.project.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskModalOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
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(async () => {
|
||||
await loadAll()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam) {
|
||||
const dashIndex = taskParam.lastIndexOf('-')
|
||||
if (dashIndex > 0) {
|
||||
const code = taskParam.slice(0, dashIndex)
|
||||
const num = Number(taskParam.slice(dashIndex + 1))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.project?.code === code && t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
{{ $t('myTasks.createTask') }}
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border 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>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View — grouped by canonical category -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex w-72 shrink-0 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 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">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByCategory(cat)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByCategory(cat).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backlog below kanban (no drag/drop — status change goes through TaskModal) -->
|
||||
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
show-status-badge
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="backlogTasks.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</p>
|
||||
</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="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"
|
||||
:selected-tasks="selectedTasksArray"
|
||||
:projects="projects"
|
||||
@toggle-all="toggleSelectAll(tasks)"
|
||||
@bulk-update="onBulkUpdate"
|
||||
@bulk-archive="onBulkArchive"
|
||||
@bulk-delete="onBulkDelete"
|
||||
/>
|
||||
<TaskListItem
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
show-project-color
|
||||
:selected="selectedTaskIds.has(task.id)"
|
||||
@click="openTaskEdit(task)"
|
||||
@toggle-select="toggleTaskSelect"
|
||||
/>
|
||||
<p
|
||||
v-if="tasks.length === 0 && !isLoading"
|
||||
class="py-8 text-center text-sm text-neutral-400"
|
||||
>
|
||||
{{ $t('myTasks.noTasks') }}
|
||||
</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"
|
||||
:task="selectedTask"
|
||||
:project-id="selectedTask?.project?.id ?? 0"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="selectedTask?.project?.id ? groups.filter(g => g.project?.id === selectedTask?.project?.id) : groups"
|
||||
:users="users"
|
||||
:projects="projects"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||
{{ $t('archive.empty') }}
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="flex cursor-pointer items-center justify-between rounded-lg border border-neutral-200 bg-white px-4 py-3 hover:shadow-sm"
|
||||
@click="openTaskEdit(task)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-bold text-neutral-400">{{ project?.code }}-{{ task.number }}</span>
|
||||
<span class="text-sm font-semibold text-neutral-900">{{ task.title }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: task.status.color }"
|
||||
>
|
||||
{{ task.status.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.group"
|
||||
class="rounded-full border px-2 py-0.5 text-xs font-semibold"
|
||||
:style="{ borderColor: task.group.color, color: task.group.color }"
|
||||
>
|
||||
{{ task.group.title }}
|
||||
</span>
|
||||
<UserAvatar
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskModal
|
||||
v-model="taskDrawerOpen"
|
||||
:task="selectedTask"
|
||||
:project-id="projectId"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Archives' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const taskService = useTaskService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const archivedTasks = ref<Task[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
|
||||
const selectedGroupId = ref<number | null>(null)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
groups.value.map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (!selectedGroupId.value) return archivedTasks.value
|
||||
return archivedTasks.value.filter(t => t.group?.id === selectedGroupId.value)
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
const [p, t, e, pr, ty, g, u] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value, true),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
archivedTasks.value = t
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProjectGroupTab :project-id="projectId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Groupes du projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const project = ref<Project | null>(null)
|
||||
|
||||
async function loadProject() {
|
||||
project.value = await projectService.getById(projectId.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProject()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openTaskCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter un ticket</span>
|
||||
<span class="sm:hidden">Ticket</span>
|
||||
</MalioButton>
|
||||
<button
|
||||
class="flex h-[40px] w-[40px] items-center justify-center rounded-md border 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>
|
||||
<MalioButtonIcon
|
||||
icon="heroicons:cog-6-tooth"
|
||||
aria-label="Paramètres du projet"
|
||||
variant="ghost"
|
||||
@click="projectDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<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"
|
||||
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
|
||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="onDragEnter(status.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropStatus($event, status)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
||||
:style="{ backgroundColor: status.color }"
|
||||
>
|
||||
{{ status.label }} ({{ tasksByStatus(status.id).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<TaskCard
|
||||
v-for="task in tasksByStatus(status.id)"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
<p
|
||||
v-if="tasksByStatus(status.id).length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
Aucun ticket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
@dragenter.prevent="onDragEnter(0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropBacklog($event)"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-neutral-900">Backlog ({{ backlogTasks.length }})</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<TaskCard
|
||||
v-for="task in backlogTasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="openTaskEdit(task)"
|
||||
/>
|
||||
</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"
|
||||
:can-archive="canArchiveSelection"
|
||||
@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"
|
||||
:project-id="projectId"
|
||||
:statuses="statuses"
|
||||
:efforts="efforts"
|
||||
:priorities="priorities"
|
||||
:tags="tags"
|
||||
:groups="groups"
|
||||
:users="users"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="projectDrawerOpen"
|
||||
:project="project"
|
||||
:clients="clients"
|
||||
@saved="onProjectSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Projet' })
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
const taskService = useTaskService()
|
||||
const effortService = useTaskEffortService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
const tagService = useTaskTagService()
|
||||
const groupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tasks = ref<Task[]>([])
|
||||
const efforts = ref<TaskEffort[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const tags = ref<TaskTag[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const statuses = computed<TaskStatus[]>(() =>
|
||||
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'kanban') {
|
||||
selectedStatusId.value = null
|
||||
}
|
||||
})
|
||||
const selectedTaskIds = reactive(new Set<number>())
|
||||
const dragOverStatusId = ref<number | null>(null)
|
||||
const dragCounter = ref(0)
|
||||
const taskDrawerOpen = ref(false)
|
||||
const projectDrawerOpen = ref(false)
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
|
||||
const groupFilterOptions = computed(() =>
|
||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
||||
)
|
||||
|
||||
const tagFilterOptions = computed(() =>
|
||||
tags.value.map(t => ({ label: t.label, value: t.id }))
|
||||
)
|
||||
|
||||
const userFilterOptions = computed(() =>
|
||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||
)
|
||||
|
||||
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 canArchiveSelection = computed(() => {
|
||||
if (selectedStatusId.value === null) return false
|
||||
const status = statuses.value.find(s => s.id === selectedStatusId.value)
|
||||
return status?.isFinal === true
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
let result = tasks.value.filter(t => !t.archived)
|
||||
if (selectedGroupId.value) {
|
||||
result = result.filter(t => t.group?.id === selectedGroupId.value)
|
||||
}
|
||||
if (selectedTagId.value) {
|
||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||
}
|
||||
if (selectedAssigneeId.value) {
|
||||
result = result.filter(t =>
|
||||
t.assignee?.id === selectedAssigneeId.value
|
||||
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||
)
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
watch(filteredTasks, (list) => {
|
||||
if (selectedTaskIds.size === 0) return
|
||||
const visibleIds = new Set(list.map(t => t.id))
|
||||
for (const id of selectedTaskIds) {
|
||||
if (!visibleIds.has(id)) selectedTaskIds.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
function tasksByStatus(statusId: number): Task[] {
|
||||
return filteredTasks.value.filter(t => t.status?.id === statusId)
|
||||
}
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
filteredTasks.value.filter(t => !t.status)
|
||||
)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
taskService.getByProject(projectId.value),
|
||||
effortService.getAll(),
|
||||
priorityService.getAll(),
|
||||
tagService.getAll(),
|
||||
groupService.getByProject(projectId.value),
|
||||
userService.getAll(),
|
||||
clientService.getAll(),
|
||||
])
|
||||
project.value = p
|
||||
tasks.value = t
|
||||
efforts.value = e
|
||||
priorities.value = pr
|
||||
tags.value = ty
|
||||
groups.value = g
|
||||
users.value = u
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openTaskCreate() {
|
||||
selectedTask.value = null
|
||||
taskDrawerOpen.value = true
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
function openTaskEdit(task: Task) {
|
||||
selectedTask.value = task
|
||||
taskDrawerOpen.value = true
|
||||
if (project.value?.code && task.number) {
|
||||
router.replace({ query: { task: `${project.value.code}-${task.number}` } })
|
||||
}
|
||||
}
|
||||
|
||||
watch(taskDrawerOpen, (open) => {
|
||||
if (!open) {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
function onDragEnter(id: number) {
|
||||
dragCounter.value++
|
||||
dragOverStatusId.value = id
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
dragOverStatusId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter.value = 0
|
||||
dragOverStatusId.value = null
|
||||
return Number(event.dataTransfer!.getData('text/plain'))
|
||||
}
|
||||
|
||||
|
||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || task.status?.id === status.id) return
|
||||
task.status = status
|
||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
||||
}
|
||||
|
||||
async function onDropBacklog(event: DragEvent) {
|
||||
const taskId = onDrop(event)
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task || !task.status) return
|
||||
task.status = null
|
||||
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()
|
||||
}
|
||||
|
||||
async function onProjectSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
const taskParam = route.query.task as string | undefined
|
||||
if (taskParam && project.value) {
|
||||
const prefix = `${project.value.code}-`
|
||||
if (taskParam.startsWith(prefix)) {
|
||||
const num = Number(taskParam.slice(prefix.length))
|
||||
if (num) {
|
||||
const task = tasks.value.find(t => t.number === num)
|
||||
if (task) {
|
||||
openTaskEdit(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:icon-name="showArchived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-outline'"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
@click="toggleArchived"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ showArchived ? $t('projects.hideArchived') : $t('projects.showArchived') }}</span>
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 shrink-0"
|
||||
@click="openCreate"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ $t('projects.addProject') }}</span>
|
||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="cursor-pointer p-4 shadow-sm transition hover:shadow-md"
|
||||
:class="{ 'opacity-60': project.archived }"
|
||||
:style="projectCardStyle(project.color)"
|
||||
@click="navigateTo(`/projects/${project.id}`)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-md font-bold" :style="{ color: project.color }">{{ project.name }}</h3>
|
||||
<span
|
||||
v-if="project.archived"
|
||||
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700"
|
||||
>
|
||||
{{ $t('common.archived') }}
|
||||
</span>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
aria-label="Modifier le projet"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openEdit(project)"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-neutral-600 line-clamp-4">
|
||||
{{ project.description ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projects.length === 0 && !isLoading"
|
||||
class="col-span-full py-12 text-center text-neutral-400"
|
||||
>
|
||||
{{ showArchived ? $t('projects.noArchivedProjects') : $t('projects.noProjects') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectDrawer
|
||||
v-model="drawerOpen"
|
||||
:project="selectedProject"
|
||||
:clients="clients"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
useHead({ title: 'Projets' })
|
||||
|
||||
function projectCardStyle(color: string | null) {
|
||||
const hex = (color || '#222783').replace('#', '')
|
||||
const r = parseInt(hex.substring(0, 2), 16)
|
||||
const g = parseInt(hex.substring(2, 4), 16)
|
||||
const b = parseInt(hex.substring(4, 6), 16)
|
||||
return {
|
||||
borderRadius: '16px',
|
||||
backgroundColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
|
||||
}
|
||||
}
|
||||
|
||||
const projectService = useProjectService()
|
||||
const clientService = useClientService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedProject = ref<Project | null>(null)
|
||||
const showArchived = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, c] = await Promise.all([
|
||||
projectService.getAll({ archived: showArchived.value }),
|
||||
clientService.getAll(),
|
||||
])
|
||||
projects.value = p
|
||||
clients.value = c
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleArchived() {
|
||||
showArchived.value = !showArchived.value
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedProject.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(project: Project) {
|
||||
selectedProject.value = project
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Workflow } from './workflow'
|
||||
|
||||
export type Project = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
client: Client | null
|
||||
workflow: Workflow
|
||||
giteaOwner: string | null
|
||||
giteaRepo: string | null
|
||||
bookstackShelfId: number | null
|
||||
bookstackShelfName: string | null
|
||||
archived: boolean
|
||||
taskCount: number
|
||||
}
|
||||
|
||||
export type ProjectWrite = {
|
||||
code?: string
|
||||
name: string
|
||||
description: string | null
|
||||
color: string
|
||||
client: string | null // IRI : "/api/clients/1" ou null
|
||||
workflow?: string // IRI : "/api/workflows/1"
|
||||
giteaOwner?: string | null
|
||||
giteaRepo?: string | null
|
||||
bookstackShelfId?: number | null
|
||||
bookstackShelfName?: string | null
|
||||
archived?: boolean
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
export type TaskDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
task: string
|
||||
originalName: string
|
||||
fileName?: string | null
|
||||
sharePath?: string | null
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy: UserData | null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type TaskEffort = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type TaskEffortWrite = {
|
||||
label: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Project } from './project'
|
||||
|
||||
export type TaskGroup = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: Project | null
|
||||
archived: boolean
|
||||
}
|
||||
|
||||
export type TaskGroupWrite = {
|
||||
title: string
|
||||
description: string | null
|
||||
color: string
|
||||
project: string
|
||||
archived?: boolean
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type TaskPriority = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TaskPriorityWrite = {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type TaskRecurrence = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
}
|
||||
|
||||
export type TaskRecurrenceWrite = {
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek?: string[] | null
|
||||
dayOfMonth?: number | null
|
||||
weekOfMonth?: number | null
|
||||
endDate?: string | null
|
||||
maxOccurrences?: number | null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { StatusCategory } from './workflow'
|
||||
|
||||
export type TaskStatus = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: { '@id': string, id: number } | string
|
||||
}
|
||||
|
||||
export type TaskStatusWrite = {
|
||||
label: string
|
||||
color: string
|
||||
position: number
|
||||
isFinal: boolean
|
||||
category: StatusCategory
|
||||
workflow?: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type TaskTag = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type TaskTagWrite = {
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TaskStatus } from './task-status'
|
||||
import type { TaskEffort } from './task-effort'
|
||||
import type { TaskPriority } from './task-priority'
|
||||
import type { TaskTag } from './task-tag'
|
||||
import type { TaskGroup } from './task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from './project'
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type Task = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
number: number
|
||||
title: string
|
||||
description: string | null
|
||||
status: TaskStatus | null
|
||||
effort: TaskEffort | null
|
||||
priority: TaskPriority | null
|
||||
assignee: UserData | null
|
||||
collaborators: UserData[]
|
||||
group: TaskGroup | null
|
||||
project: Project | null
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
syncToCalendar: boolean
|
||||
calendarSyncError: string | null
|
||||
recurrence: {
|
||||
id: number
|
||||
'@id'?: string
|
||||
type: 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
interval: number
|
||||
daysOfWeek: string[] | null
|
||||
dayOfMonth: number | null
|
||||
weekOfMonth: number | null
|
||||
endDate: string | null
|
||||
maxOccurrences: number | null
|
||||
occurrenceCount: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export type TaskWrite = {
|
||||
title: string
|
||||
description: string | null
|
||||
status: string | null
|
||||
effort: string | null
|
||||
priority: string | null
|
||||
assignee: string | null
|
||||
collaborators?: string[]
|
||||
group: string | null
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
syncToCalendar?: boolean
|
||||
recurrence?: string | null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TaskStatus, TaskStatusWrite } from './task-status'
|
||||
|
||||
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
|
||||
|
||||
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||
todo: 'À faire',
|
||||
in_progress: 'En cours',
|
||||
blocked: 'Bloqué',
|
||||
review: 'En validation',
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
|
||||
export type Workflow = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses: TaskStatus[]
|
||||
}
|
||||
|
||||
export type WorkflowWrite = {
|
||||
name: string
|
||||
isDefault: boolean
|
||||
position: number
|
||||
statuses?: TaskStatusWrite[]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Project, ProjectWrite } from './dto/project'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useProjectService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(params?: { archived?: boolean }): Promise<Project[]> {
|
||||
const query = params?.archived !== undefined ? `?archived=${params.archived}` : ''
|
||||
const data = await api.get<HydraCollection<Project>>(`/projects${query}`)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Project> {
|
||||
return api.get<Project>(`/projects/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ProjectWrite): Promise<Project> {
|
||||
return api.post<Project>('/projects', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'projects.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ProjectWrite>, options?: { toastSuccessKey?: string }): Promise<Project> {
|
||||
return api.patch<Project>(`/projects/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: options?.toastSuccessKey ?? 'projects.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/projects/${id}`, {}, {
|
||||
toastSuccessKey: 'projects.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { TaskDocument } from './dto/task-document'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
export function useTaskDocumentService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
|
||||
async function getByTask(taskId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
task: `/api/tasks/${taskId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function uploadWithRelation(relationField: string, relationIri: string, file: File): Promise<TaskDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append(relationField, relationIri)
|
||||
|
||||
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async function upload(taskId: number, file: File): Promise<TaskDocument> {
|
||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||
}
|
||||
|
||||
async function linkShare(taskId: number, sharePath: string): Promise<TaskDocument> {
|
||||
return $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task: `/api/tasks/${taskId}`, sharePath }),
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
function getDownloadUrl(id: number): string {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
async function getContent(id: number): Promise<string> {
|
||||
return $fetch<string>(`${baseURL}/task_documents/${id}/download`, {
|
||||
credentials: 'include',
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
return { getByTask, upload, linkShare, remove, getDownloadUrl, getContent }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TaskEffort, TaskEffortWrite } from './dto/task-effort'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskEffortService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskEffort[]> {
|
||||
const data = await api.get<HydraCollection<TaskEffort>>('/task_efforts')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskEffortWrite): Promise<TaskEffort> {
|
||||
return api.post<TaskEffort>('/task_efforts', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskEfforts.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskEffortWrite>): Promise<TaskEffort> {
|
||||
return api.patch<TaskEffort>(`/task_efforts/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskEfforts.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_efforts/${id}`, {}, {
|
||||
toastSuccessKey: 'taskEfforts.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TaskGroup, TaskGroupWrite } from './dto/task-group'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskGroupService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskGroup[]> {
|
||||
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getByProject(projectId: number): Promise<TaskGroup[]> {
|
||||
const data = await api.get<HydraCollection<TaskGroup>>('/task_groups', {
|
||||
project: `/api/projects/${projectId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskGroupWrite): Promise<TaskGroup> {
|
||||
return api.post<TaskGroup>('/task_groups', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskGroups.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskGroupWrite>): Promise<TaskGroup> {
|
||||
return api.patch<TaskGroup>(`/task_groups/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskGroups.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_groups/${id}`, {}, {
|
||||
toastSuccessKey: 'taskGroups.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getByProject, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TaskPriority, TaskPriorityWrite } from './dto/task-priority'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskPriorityService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskPriority[]> {
|
||||
const data = await api.get<HydraCollection<TaskPriority>>('/task_priorities')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskPriorityWrite): Promise<TaskPriority> {
|
||||
return api.post<TaskPriority>('/task_priorities', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskPriorities.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskPriorityWrite>): Promise<TaskPriority> {
|
||||
return api.patch<TaskPriority>(`/task_priorities/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskPriorities.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_priorities/${id}`, {}, {
|
||||
toastSuccessKey: 'taskPriorities.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TaskRecurrence, TaskRecurrenceWrite } from './dto/task-recurrence'
|
||||
|
||||
export function useTaskRecurrenceService() {
|
||||
const api = useApi()
|
||||
|
||||
async function create(payload: TaskRecurrenceWrite): Promise<TaskRecurrence> {
|
||||
return api.post<TaskRecurrence>('/task_recurrences', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskRecurrence.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskRecurrenceWrite>): Promise<TaskRecurrence> {
|
||||
return api.patch<TaskRecurrence>(`/task_recurrences/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskRecurrence.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_recurrences/${id}`, {}, {
|
||||
toastSuccessKey: 'taskRecurrence.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TaskStatus, TaskStatusWrite } from './dto/task-status'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskStatusService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskStatus[]> {
|
||||
const data = await api.get<HydraCollection<TaskStatus>>('/task_statuses')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskStatusWrite): Promise<TaskStatus> {
|
||||
return api.post<TaskStatus>('/task_statuses', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskStatusWrite>): Promise<TaskStatus> {
|
||||
return api.patch<TaskStatus>(`/task_statuses/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskStatuses.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_statuses/${id}`, {}, {
|
||||
toastSuccessKey: 'taskStatuses.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { TaskTag, TaskTagWrite } from './dto/task-tag'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskTagService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<TaskTag[]> {
|
||||
const data = await api.get<HydraCollection<TaskTag>>('/task_tags')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskTagWrite): Promise<TaskTag> {
|
||||
return api.post<TaskTag>('/task_tags', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskTags.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskTagWrite>): Promise<TaskTag> {
|
||||
return api.patch<TaskTag>(`/task_tags/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'taskTags.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_tags/${id}`, {}, {
|
||||
toastSuccessKey: 'taskTags.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Task, TaskWrite } from './dto/task'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useTaskService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getByProject(projectId: number, archived = false): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', {
|
||||
project: `/api/projects/${projectId}`,
|
||||
archived,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getFiltered(params: Record<string, string | number | boolean | string[]>): Promise<Task[]> {
|
||||
const data = await api.get<HydraCollection<Task>>('/tasks', params as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: TaskWrite): Promise<Task> {
|
||||
return api.post<Task>('/tasks', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'tasks.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<TaskWrite>): Promise<Task> {
|
||||
return api.patch<Task>(`/tasks/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'tasks.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/tasks/${id}`, {}, {
|
||||
toastSuccessKey: 'tasks.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getByProject, getFiltered, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Workflow, WorkflowWrite } from './dto/workflow'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type SwitchPayload = {
|
||||
workflowId: number
|
||||
mapping: Record<string, number | null>
|
||||
}
|
||||
|
||||
type SwitchResult = {
|
||||
projectId: number
|
||||
workflowId: number
|
||||
migratedTaskCount: number
|
||||
}
|
||||
|
||||
export function useWorkflowService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Workflow[]> {
|
||||
const data = await api.get<HydraCollection<Workflow>>('/workflows')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getOne(id: number): Promise<Workflow> {
|
||||
return api.get<Workflow>(`/workflows/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: WorkflowWrite): Promise<Workflow> {
|
||||
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'workflows.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
|
||||
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'workflows.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/workflows/${id}`, {}, {
|
||||
toastSuccessKey: 'workflows.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
|
||||
return api.post<SwitchResult>(
|
||||
`/projects/${projectId}/switch-workflow`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'workflows.switched' },
|
||||
)
|
||||
}
|
||||
|
||||
return { getAll, getOne, create, update, remove, switchOnProject }
|
||||
}
|
||||
Reference in New Issue
Block a user