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:
Matthieu
2026-06-20 17:06:13 +02:00
parent c90d91d6c4
commit 7446b7dca9
60 changed files with 143 additions and 142 deletions
@@ -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 }
}