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>