Merge branch 'develop' into feat/mail-integration

This commit is contained in:
2026-05-20 07:45:09 +00:00
53 changed files with 5666 additions and 506 deletions

View File

@@ -22,7 +22,7 @@
<div>
<AdminClientTab v-if="activeTab === 'clients'" />
<AdminStatusTab v-if="activeTab === 'statuses'" />
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
<AdminEffortTab v-if="activeTab === 'efforts'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
@@ -41,7 +41,7 @@ useHead({ title: 'Administration' })
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'statuses', label: 'Statuts' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },

168
frontend/pages/help.vue Normal file
View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { marked } from 'marked'
useHead({ title: 'Aide' })
type Section = {
id: string
title: string
icon: string
accent: string
roles: ('admin' | 'user' | 'client')[]
content: string
}
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
}
const sections = computed<Section[]>(() => {
return Object.entries(rawModules).map(([path, raw]) => {
const id = path.split('/').pop()!.replace(/\.md$/, '')
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
return { id, ...meta, content: raw }
}).sort((a, b) => a.id.localeCompare(b.id))
})
const auth = useAuthStore()
const userRole = computed<'admin' | 'user' | 'client'>(() => {
const roles = auth.user?.roles ?? []
if (roles.includes('ROLE_ADMIN')) return 'admin'
if (roles.includes('ROLE_CLIENT')) return 'client'
return 'user'
})
const visibleSections = computed(() =>
sections.value.filter(s => s.roles.includes(userRole.value)),
)
const route = useRoute()
const router = useRouter()
const activeId = ref(visibleSections.value[0]?.id ?? '')
onMounted(() => {
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
if (hash && visibleSections.value.some(s => s.id === hash)) {
activeId.value = hash
}
})
watch(activeId, (id) => {
router.replace({ query: { ...route.query, section: id } })
})
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
const renderedHtml = computed(() => {
if (!activeSection.value) return ''
return marked.parse(activeSection.value.content, { async: false }) as string
})
const prevSection = computed(() => {
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
return idx > 0 ? visibleSections.value[idx - 1] : null
})
const nextSection = computed(() => {
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
})
</script>
<template>
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
<!-- Sidebar -->
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
<div class="mb-4 flex items-center gap-2 lg:mb-6">
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
<Icon name="mdi:lifebuoy" size="20" />
</div>
<div>
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
</div>
</div>
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
<button
v-for="section in visibleSections"
:key="section.id"
type="button"
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
:class="activeId === section.id
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
@click="activeId = section.id"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
:class="section.accent"
>
<Icon :name="section.icon" size="16" />
</span>
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
</button>
</nav>
</aside>
<!-- Content -->
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
<div v-if="activeSection" class="mx-auto max-w-3xl">
<!-- Hero header -->
<div
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
:class="activeSection.accent"
>
<div class="flex items-center gap-4">
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
<Icon :name="activeSection.icon" size="28" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
</div>
</div>
</div>
<!-- Markdown content -->
<article
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
v-html="renderedHtml"
/>
<!-- Footer nav -->
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
:disabled="!prevSection"
@click="prevSection && (activeId = prevSection.id)"
>
<Icon name="mdi:arrow-left" size="18" />
<span>{{ prevSection?.title ?? '' }}</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
:disabled="!nextSection"
@click="nextSection && (activeId = nextSection.id)"
>
<span>{{ nextSection?.title ?? '' }}</span>
<Icon name="mdi:arrow-right" size="18" />
</button>
</div>
</div>
</main>
</div>
</template>

View File

@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
@@ -60,6 +62,7 @@ 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)
@@ -112,13 +115,11 @@ const sortOptions = computed(() => [
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
])
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
// Kanban helpers (grouped by canonical status category)
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
function tasksByCategory(category: StatusCategory): Task[] {
return tasks.value.filter(t => t.status?.category === category)
}
const backlogTasks = computed(() =>
@@ -205,44 +206,6 @@ watch(selectedProjectId, () => {
selectedGroupId.value = null
}, { flush: 'sync' })
// Drag & drop
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
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 })
}
// Modal
function openTaskCreate() {
selectedTask.value = null
@@ -428,36 +391,29 @@ onMounted(async () => {
</div>
</div>
<!-- Kanban View -->
<!-- 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="status in sortedStatuses"
:key="status.id"
class="flex min-w-36 flex-1 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)"
v-for="cat in CATEGORIES"
:key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
>
<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 class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
{{ 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 tasksByStatus(status.id)"
v-for="task in tasksByCategory(cat)"
:key="task.id"
:task="task"
show-project-color
show-status-badge
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
v-if="tasksByCategory(cat).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
@@ -467,15 +423,8 @@ onMounted(async () => {
</div>
</div>
<!-- Backlog below kanban -->
<div
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(0)"
@dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)"
>
<!-- 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
@@ -483,6 +432,7 @@ onMounted(async () => {
:key="task.id"
:task="task"
show-project-color
show-status-badge
@click="openTaskEdit(task)"
/>
</div>
@@ -507,6 +457,8 @@ onMounted(async () => {
:priorities="priorities"
:efforts="efforts"
:groups="groups"
:selected-tasks="selectedTasksArray"
:projects="projects"
@toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"

View File

@@ -82,7 +82,6 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useProjectService } from '~/services/projects'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
@@ -96,7 +95,6 @@ useHead({ title: 'Archives' })
const projectService = useProjectService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
@@ -105,8 +103,11 @@ const userService = useUserService()
const project = ref<Project | null>(null)
const archivedTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
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[]>([])
@@ -126,10 +127,9 @@ const filteredTasks = computed(() => {
})
async function loadData() {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
const [p, t, e, pr, ty, g, u] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value, true),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
@@ -138,7 +138,6 @@ async function loadData() {
])
project.value = p
archivedTasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty

View File

@@ -218,7 +218,6 @@ import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects'
import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
@@ -234,7 +233,6 @@ useHead({ title: 'Projet' })
const projectService = useProjectService()
const clientService = useClientService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
@@ -243,7 +241,6 @@ const userService = useUserService()
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
@@ -252,6 +249,10 @@ 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)
@@ -333,10 +334,9 @@ const backlogTasks = computed(() =>
async function loadData() {
isLoading.value = true
try {
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
@@ -346,7 +346,6 @@ async function loadData() {
])
project.value = p
tasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty