1510 lines
65 KiB
Markdown
1510 lines
65 KiB
Markdown
# Mail Integration — Phase 6 : Intégration Tâches
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Brancher l'intégration mail ↔ tâches : modal "Créer tâche depuis mail" (pré-remplie subject+body), modal "Lier mail à tâche existante" (autocomplete), nouvel onglet "Mails" dans `TaskModal.vue` listant les mails liés.
|
|
|
|
**Architecture:** 3 nouveaux composants modals sous `components/mail/`, modification de `TaskModal.vue` pour ajouter un onglet `mails` à côté des onglets `details` et `planning` existants, branchement des handlers placeholders dans `pages/mail.vue`. Pas de nouveau backend (endpoints Phase 3 déjà en place).
|
|
|
|
**Contexte codebase important :**
|
|
- Il n'y a pas de `TaskDrawer.vue` — le composant principal est `TaskModal.vue` (`frontend/components/task/TaskModal.vue`), qui contient un système d'onglets `details` / `planning`.
|
|
- `TaskModal.vue` est ouvert via `v-model: boolean` (prop `modelValue`) et reçoit la tâche complète via la prop `task: Task | null`.
|
|
- Le service `useMailService()` expose déjà : `createTaskFromMail(mailId, input)`, `linkTask(mailId, taskId)`, `listMailsForTask(taskId)`.
|
|
- `MailCreateTaskInput` est `{ projectId: number; taskGroupId?: number | null; priority?: string | null }` — le backend dérive titre/description du mail, le frontend passe uniquement les affectations.
|
|
- `useProjectService().getAll()`, `useTaskGroupService().getByProject(projectId)`, `useTaskPriorityService().getAll()` sont les services de sélection.
|
|
- Le projet est SPA (SSR off) — pas de guard `import.meta.client` nécessaire pour DOM.
|
|
- Pattern modal existant : `<Teleport to="body">` + `<Transition>` + backdrop click pour fermer (voir `TaskModal.vue`).
|
|
- `MalioSelect` requiert `{ label: string, value: number | null }` — utiliser `<select>` natif pour les enums string (ex: priority qui est une string).
|
|
|
|
**Tech Stack:** Nuxt 4, Vue 3 Composition API + `<script setup lang="ts">`, Pinia, @malio/layer-ui (MalioButton, MalioButtonIcon, MalioSelect), Tailwind CSS, @nuxt/icon.
|
|
|
|
**Branche cible :** `feat/mail-integration` (vérifier qu'elle est active avant de commencer).
|
|
|
|
**Fichiers créés/modifiés par le codeur :**
|
|
|
|
| Fichier | Action |
|
|
|---|---|
|
|
| `frontend/components/mail/MailCreateTaskModal.vue` | Créer |
|
|
| `frontend/components/mail/MailLinkTaskModal.vue` | Créer |
|
|
| `frontend/components/mail/MailPickerModal.vue` | Créer |
|
|
| `frontend/components/task/TaskModal.vue` | Modifier (ajout onglet `mails`) |
|
|
| `frontend/pages/mail.vue` | Modifier (brancher handlers placeholders) |
|
|
| `frontend/i18n/locales/fr.json` | Modifier (ajout sous-clés `mail.createTaskModal`, `mail.linkTaskModal`, `mail.pickerModal`, `mail.taskTab`) |
|
|
| `frontend/i18n/locales/en.json` | Modifier si le fichier existe |
|
|
|
|
---
|
|
|
|
### Task 1 : Vérification de l'environnement
|
|
|
|
- [ ] **Step 1 : Vérifier la branche active**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime branch --show-current
|
|
```
|
|
|
|
Attendu : `feat/mail-integration`. Si non :
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérifier que les fichiers Phase 5 existent**
|
|
|
|
```bash
|
|
ls -la /home/r-dev/malio-dev/Lesstime/frontend/pages/mail.vue \
|
|
/home/r-dev/malio-dev/Lesstime/frontend/components/mail/MailMessageViewer.vue \
|
|
/home/r-dev/malio-dev/Lesstime/frontend/services/mail.ts \
|
|
/home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.ts
|
|
```
|
|
|
|
Attendu : les 4 fichiers présents. Si absents, implémenter Phase 5 d'abord.
|
|
|
|
- [ ] **Step 3 : Vérifier le baseline TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | head -30
|
|
```
|
|
|
|
Noter les erreurs existantes (si any) pour les distinguer des erreurs Phase 6.
|
|
|
|
---
|
|
|
|
### Task 2 : Clés i18n Phase 6 — `frontend/i18n/locales/fr.json`
|
|
|
|
Ajouter les sous-clés dans le bloc `mail` existant (après la clé `task` déjà présente en Phase 5).
|
|
|
|
- [ ] **Step 1 : Ajouter les sous-clés dans `fr.json`**
|
|
|
|
Dans le bloc `"mail": { ... }`, ajouter après `"task": { ... }` :
|
|
|
|
```json
|
|
"createTaskModal": {
|
|
"title": "Créer une tâche depuis ce mail",
|
|
"submit": "Créer la tâche",
|
|
"projectLabel": "Projet *",
|
|
"projectPlaceholder": "Sélectionner un projet",
|
|
"groupLabel": "Groupe (optionnel)",
|
|
"groupPlaceholder": "Aucun groupe",
|
|
"priorityLabel": "Priorité (optionnelle)",
|
|
"priorityPlaceholder": "Aucune priorité",
|
|
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
|
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
|
},
|
|
"linkTaskModal": {
|
|
"title": "Lier à une tâche existante",
|
|
"submit": "Lier la tâche",
|
|
"searchPlaceholder": "Rechercher une tâche par titre…",
|
|
"projectFilter": "Filtrer par projet",
|
|
"projectAll": "Tous les projets",
|
|
"empty": "Aucune tâche correspondante.",
|
|
"loading": "Recherche en cours…"
|
|
},
|
|
"pickerModal": {
|
|
"title": "Lier un mail à cette tâche",
|
|
"searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
|
|
"empty": "Aucun mail correspondant.",
|
|
"loading": "Chargement des mails…",
|
|
"submit": "Lier ce mail"
|
|
},
|
|
"taskTab": {
|
|
"title": "Mails",
|
|
"empty": "Aucun mail lié à cette tâche.",
|
|
"linkButton": "Lier un mail",
|
|
"openInMailer": "Ouvrir dans la messagerie",
|
|
"unlinkConfirm": "Délier ce mail ?"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2 : Si `en.json` existe, ajouter les traductions anglaises**
|
|
|
|
```bash
|
|
ls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/
|
|
```
|
|
|
|
Si `en.json` existe, ajouter dans le bloc `"mail"` :
|
|
|
|
```json
|
|
"createTaskModal": {
|
|
"title": "Create a task from this mail",
|
|
"submit": "Create task",
|
|
"projectLabel": "Project *",
|
|
"projectPlaceholder": "Select a project",
|
|
"groupLabel": "Group (optional)",
|
|
"groupPlaceholder": "No group",
|
|
"priorityLabel": "Priority (optional)",
|
|
"priorityPlaceholder": "No priority",
|
|
"titleHint": "Title will be filled from the mail subject.",
|
|
"descriptionHint": "Description will be filled from the mail body."
|
|
},
|
|
"linkTaskModal": {
|
|
"title": "Link to an existing task",
|
|
"submit": "Link task",
|
|
"searchPlaceholder": "Search a task by title…",
|
|
"projectFilter": "Filter by project",
|
|
"projectAll": "All projects",
|
|
"empty": "No matching task.",
|
|
"loading": "Searching…"
|
|
},
|
|
"pickerModal": {
|
|
"title": "Link a mail to this task",
|
|
"searchPlaceholder": "Search a mail (subject, sender)…",
|
|
"empty": "No matching mail.",
|
|
"loading": "Loading mails…",
|
|
"submit": "Link this mail"
|
|
},
|
|
"taskTab": {
|
|
"title": "Mails",
|
|
"empty": "No mail linked to this task.",
|
|
"linkButton": "Link a mail",
|
|
"openInMailer": "Open in mailer",
|
|
"unlinkConfirm": "Unlink this mail?"
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Valider la syntaxe JSON**
|
|
|
|
```bash
|
|
node -e "JSON.parse(require('fs').readFileSync('/home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/fr.json', 'utf8')); console.log('JSON OK')"
|
|
```
|
|
|
|
Attendu : `JSON OK`. Corriger la virgule manquante si erreur.
|
|
|
|
- [ ] **Step 4 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/i18n/locales/fr.json frontend/i18n/locales/en.json 2>/dev/null; true
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : clés i18n Phase 6 — createTaskModal, linkTaskModal, pickerModal, taskTab"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3 : Composant `MailCreateTaskModal.vue`
|
|
|
|
Modal pour créer une tâche depuis un mail. Le backend (`POST /api/mail/messages/{id}/create-task`) dérive automatiquement le titre (subject) et la description (body plain text) — le frontend passe uniquement l'affectation (projet, groupe, priorité).
|
|
|
|
Le composant charge les projets au `onMounted`, puis charge les groupes quand le projet est sélectionné, et les priorités une seule fois. La priorité est une string (en IRI ou slug selon le backend) — utiliser `<select>` natif (pas `MalioSelect`) car les values sont des strings ou null.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailCreateTaskModal.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
|
import type { Task } from '~/services/dto/task'
|
|
import type { Project } from '~/services/dto/project'
|
|
import type { TaskGroup } from '~/services/dto/task-group'
|
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
|
import { useMailService } from '~/services/mail'
|
|
import { useProjectService } from '~/services/projects'
|
|
import { useTaskGroupService } from '~/services/task-groups'
|
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
|
|
|
const props = defineProps<{
|
|
/** v-model: true = modal ouvert */
|
|
modelValue: boolean
|
|
/** ID BDD du message source */
|
|
messageId: number
|
|
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
|
|
messageDetail: MailMessageDetailDto | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: boolean]
|
|
/** Émis après création réussie — payload = tâche créée */
|
|
created: [task: Task]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const mailService = useMailService()
|
|
const projectService = useProjectService()
|
|
const taskGroupService = useTaskGroupService()
|
|
const priorityService = useTaskPriorityService()
|
|
|
|
// ─── État formulaire ──────────────────────────────────────────────────────
|
|
|
|
const projectId = ref<number | null>(null)
|
|
const taskGroupId = ref<number | null>(null)
|
|
const priorityId = ref<number | null>(null)
|
|
const isSubmitting = ref(false)
|
|
const touchedProject = ref(false)
|
|
|
|
// ─── Données de référence ─────────────────────────────────────────────────
|
|
|
|
const projects = ref<Project[]>([])
|
|
const groups = ref<TaskGroup[]>([])
|
|
const priorities = ref<TaskPriority[]>([])
|
|
const loadingGroups = ref(false)
|
|
|
|
const projectOptions = computed(() =>
|
|
projects.value.map(p => ({ label: p.name, value: p.id }))
|
|
)
|
|
|
|
const groupOptions = computed(() => [
|
|
{ label: t('mail.createTaskModal.groupPlaceholder'), value: null },
|
|
...groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
|
])
|
|
|
|
// ─── Chargement initial ───────────────────────────────────────────────────
|
|
|
|
onMounted(async () => {
|
|
const [projs, prios] = await Promise.all([
|
|
projectService.getAll({ archived: false }),
|
|
priorityService.getAll(),
|
|
])
|
|
projects.value = projs
|
|
priorities.value = prios
|
|
})
|
|
|
|
// Recharger les groupes quand le projet change
|
|
watch(projectId, async (pid) => {
|
|
taskGroupId.value = null
|
|
groups.value = []
|
|
if (!pid) return
|
|
loadingGroups.value = true
|
|
try {
|
|
groups.value = await taskGroupService.getByProject(pid)
|
|
} finally {
|
|
loadingGroups.value = false
|
|
}
|
|
})
|
|
|
|
// Reset formulaire à l'ouverture
|
|
watch(() => props.modelValue, (open) => {
|
|
if (open) {
|
|
projectId.value = null
|
|
taskGroupId.value = null
|
|
priorityId.value = null
|
|
touchedProject.value = false
|
|
}
|
|
})
|
|
|
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
|
|
function close(): void {
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
async function handleSubmit(): Promise<void> {
|
|
touchedProject.value = true
|
|
if (!projectId.value) return
|
|
|
|
isSubmitting.value = true
|
|
try {
|
|
const task = await mailService.createTaskFromMail(props.messageId, {
|
|
projectId: projectId.value,
|
|
taskGroupId: taskGroupId.value ?? undefined,
|
|
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
|
|
})
|
|
emit('created', task)
|
|
close()
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport v-if="modelValue" to="body">
|
|
<Transition name="mail-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<!-- Modal -->
|
|
<div
|
|
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
|
style="max-height: min(90vh, 640px)"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
|
<h2 class="text-base font-bold text-neutral-900">
|
|
{{ t('mail.createTaskModal.title') }}
|
|
</h2>
|
|
<MalioButtonIcon
|
|
icon="mdi:close"
|
|
aria-label="Fermer"
|
|
variant="ghost"
|
|
icon-size="20"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Corps -->
|
|
<div class="overflow-y-auto px-6 py-5 space-y-5">
|
|
<!-- Info mail source (lecture seule) -->
|
|
<div
|
|
v-if="messageDetail"
|
|
class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
|
|
>
|
|
<p class="font-medium text-neutral-800 truncate">
|
|
{{ messageDetail.header.subject ?? t('mail.noSubject') }}
|
|
</p>
|
|
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
|
{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
|
|
</p>
|
|
<p class="mt-2 text-xs text-neutral-400 italic">
|
|
{{ t('mail.createTaskModal.titleHint') }}
|
|
</p>
|
|
<p class="text-xs text-neutral-400 italic">
|
|
{{ t('mail.createTaskModal.descriptionHint') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Sélection projet -->
|
|
<div>
|
|
<MalioSelect
|
|
v-model="projectId"
|
|
:options="projectOptions"
|
|
:label="t('mail.createTaskModal.projectLabel')"
|
|
:empty-option-label="t('mail.createTaskModal.projectPlaceholder')"
|
|
min-width="w-full"
|
|
/>
|
|
<p
|
|
v-if="touchedProject && !projectId"
|
|
class="mt-1 text-xs text-red-500"
|
|
>
|
|
{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Sélection groupe (optionnel, chargé après projet) -->
|
|
<div v-if="projectId">
|
|
<MalioSelect
|
|
v-model="taskGroupId"
|
|
:options="groupOptions"
|
|
:label="t('mail.createTaskModal.groupLabel')"
|
|
:empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
|
|
min-width="w-full"
|
|
:disabled="loadingGroups"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Sélection priorité (optionnelle) — MalioSelect car les values sont number | null -->
|
|
<div>
|
|
<MalioSelect
|
|
v-model="priorityId"
|
|
:options="[
|
|
{ label: t('mail.createTaskModal.priorityPlaceholder'), value: null },
|
|
...priorities.map(p => ({ label: p.label, value: p.id }))
|
|
]"
|
|
:label="t('mail.createTaskModal.priorityLabel')"
|
|
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
|
|
min-width="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Annuler"
|
|
button-class="w-auto px-4"
|
|
@click="close"
|
|
/>
|
|
<MalioButton
|
|
:label="t('mail.createTaskModal.submit')"
|
|
button-class="w-auto px-6"
|
|
:disabled="isSubmitting"
|
|
@click="handleSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mail-modal-enter-active,
|
|
.mail-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-active > div:last-child,
|
|
.mail-modal-leave-active > div:last-child {
|
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-from,
|
|
.mail-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.mail-modal-enter-from > div:last-child {
|
|
transform: scale(0.95) translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
Points clés :
|
|
- La priorité passe en IRI `/api/task_priorities/{id}` dans le payload — conforme au pattern `TaskModal.vue`.
|
|
- `taskGroupId: undefined` (pas `null`) si non sélectionné, pour que le backend ignore le champ (le type `MailCreateTaskInput` le déclare optionnel).
|
|
- Le backdrop click ferme la modal (même pattern que `TaskModal.vue`).
|
|
- Le composant ne pré-remplit PAS le titre/description : c'est le backend qui le fait depuis le mail, le frontend ne gère que les affectations.
|
|
|
|
- [ ] **Step 2 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailCreateTaskModal" | head -10
|
|
```
|
|
|
|
Attendu : aucune erreur.
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailCreateTaskModal.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailCreateTaskModal — picker projet/groupe/priorité, appel createTaskFromMail"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4 : Composant `MailLinkTaskModal.vue`
|
|
|
|
Modal pour lier un mail à une tâche existante. Autocomplete en input + dropdown filtré. Debounce 300ms sur la recherche. Filtre optionnel par projet. Statut archivé exclu (paramètre `archived: false`).
|
|
|
|
Le service `useTaskService().getFiltered()` accepte des params libres — utiliser `{ title: searchTerm, archived: false }` + éventuellement `project: '/api/projects/{id}'`.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailLinkTaskModal.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { Task } from '~/services/dto/task'
|
|
import type { Project } from '~/services/dto/project'
|
|
import { useMailService } from '~/services/mail'
|
|
import { useTaskService } from '~/services/tasks'
|
|
import { useProjectService } from '~/services/projects'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
/** ID BDD du message à lier */
|
|
messageId: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: boolean]
|
|
/** Émis après liaison réussie — payload = id de la tâche liée */
|
|
linked: [taskId: number]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const mailService = useMailService()
|
|
const taskService = useTaskService()
|
|
const projectService = useProjectService()
|
|
|
|
// ─── État recherche ───────────────────────────────────────────────────────
|
|
|
|
const searchQuery = ref('')
|
|
const filterProjectId = ref<number | null>(null)
|
|
const results = ref<Task[]>([])
|
|
const selectedTask = ref<Task | null>(null)
|
|
const isLoading = ref(false)
|
|
const isSubmitting = ref(false)
|
|
|
|
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
|
|
|
const projects = ref<Project[]>([])
|
|
|
|
const projectFilterOptions = computed(() => [
|
|
{ label: t('mail.linkTaskModal.projectAll'), value: null },
|
|
...projects.value.map(p => ({ label: p.name, value: p.id })),
|
|
])
|
|
|
|
onMounted(async () => {
|
|
projects.value = await projectService.getAll({ archived: false })
|
|
})
|
|
|
|
// ─── Debounce recherche ───────────────────────────────────────────────────
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
watch([searchQuery, filterProjectId], () => {
|
|
selectedTask.value = null
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(() => {
|
|
void runSearch()
|
|
}, 300)
|
|
})
|
|
|
|
async function runSearch(): Promise<void> {
|
|
const q = searchQuery.value.trim()
|
|
if (!q && !filterProjectId.value) {
|
|
results.value = []
|
|
return
|
|
}
|
|
isLoading.value = true
|
|
try {
|
|
const params: Record<string, string | number | boolean | string[]> = {
|
|
archived: false,
|
|
}
|
|
if (q) params['title'] = q
|
|
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
|
results.value = await taskService.getFiltered(params)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
|
|
|
watch(() => props.modelValue, (open) => {
|
|
if (open) {
|
|
searchQuery.value = ''
|
|
filterProjectId.value = null
|
|
results.value = []
|
|
selectedTask.value = null
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
})
|
|
|
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
|
|
function close(): void {
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
function selectTask(task: Task): void {
|
|
selectedTask.value = task
|
|
}
|
|
|
|
async function handleSubmit(): Promise<void> {
|
|
if (!selectedTask.value) return
|
|
isSubmitting.value = true
|
|
try {
|
|
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
|
emit('linked', selectedTask.value.id)
|
|
close()
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport v-if="modelValue" to="body">
|
|
<Transition name="mail-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<div
|
|
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
|
style="max-height: min(90vh, 640px)"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
|
<h2 class="text-base font-bold text-neutral-900">
|
|
{{ t('mail.linkTaskModal.title') }}
|
|
</h2>
|
|
<MalioButtonIcon
|
|
icon="mdi:close"
|
|
aria-label="Fermer"
|
|
variant="ghost"
|
|
icon-size="20"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Corps -->
|
|
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
|
<!-- Filtre projet -->
|
|
<MalioSelect
|
|
v-model="filterProjectId"
|
|
:options="projectFilterOptions"
|
|
:label="t('mail.linkTaskModal.projectFilter')"
|
|
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
|
min-width="w-full"
|
|
/>
|
|
|
|
<!-- Recherche tâche -->
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
|
{{ t('mail.linkTaskModal.title') }}
|
|
</label>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
|
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Résultats -->
|
|
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
|
<!-- Chargement -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
|
>
|
|
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
|
{{ t('mail.linkTaskModal.loading') }}
|
|
</div>
|
|
|
|
<!-- Vide -->
|
|
<div
|
|
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
|
class="py-6 text-center text-sm text-neutral-400 italic"
|
|
>
|
|
{{ t('mail.linkTaskModal.empty') }}
|
|
</div>
|
|
|
|
<!-- Liste résultats -->
|
|
<button
|
|
v-for="task in results"
|
|
:key="task.id"
|
|
type="button"
|
|
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
|
:class="selectedTask?.id === task.id
|
|
? 'bg-primary-50 border-l-2 border-primary-500'
|
|
: 'border-l-2 border-transparent'"
|
|
@click="selectTask(task)"
|
|
>
|
|
<Icon
|
|
name="material-symbols:task-outline"
|
|
size="16"
|
|
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate font-medium text-neutral-800">
|
|
{{ task.title }}
|
|
</p>
|
|
<p
|
|
v-if="task.project"
|
|
class="truncate text-xs text-neutral-500"
|
|
>
|
|
{{ task.project.name }}
|
|
<span v-if="task.project.code && task.number">
|
|
— {{ task.project.code }}-{{ task.number }}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<Icon
|
|
v-if="selectedTask?.id === task.id"
|
|
name="material-symbols:check-circle"
|
|
size="16"
|
|
class="flex-shrink-0 text-primary-500"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Annuler"
|
|
button-class="w-auto px-4"
|
|
@click="close"
|
|
/>
|
|
<MalioButton
|
|
:label="t('mail.linkTaskModal.submit')"
|
|
button-class="w-auto px-6"
|
|
:disabled="!selectedTask || isSubmitting"
|
|
@click="handleSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mail-modal-enter-active,
|
|
.mail-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-active > div:last-child,
|
|
.mail-modal-leave-active > div:last-child {
|
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-from,
|
|
.mail-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.mail-modal-enter-from > div:last-child {
|
|
transform: scale(0.95) translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
Points clés :
|
|
- Debounce 300ms — le timer est nettoyé dans `onBeforeUnmount` pour éviter les fuites.
|
|
- Sélection mono-tâche (click toggle sélection visuelle, un seul peut être sélectionné à la fois).
|
|
- Le bouton "Lier" est désactivé si aucune tâche sélectionnée ou si soumission en cours.
|
|
- La recherche ne se déclenche PAS si query vide ET pas de filtre projet (évite de charger toutes les tâches au montage).
|
|
|
|
- [ ] **Step 2 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailLinkTaskModal" | head -10
|
|
```
|
|
|
|
Attendu : aucune erreur.
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailLinkTaskModal.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailLinkTaskModal — autocomplete tâches, filtre projet, debounce 300ms"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5 : Composant `MailPickerModal.vue`
|
|
|
|
Modal utilisé depuis l'onglet "Mails" de `TaskModal.vue` pour lier un mail existant à la tâche ouverte. Charge les messages récents depuis le dossier INBOX (ou le dernier dossier sélectionné dans le store), affiche une liste filtrable, appelle `mailService.linkTask(messageId, taskId)`.
|
|
|
|
- [ ] **Step 1 : Créer `frontend/components/mail/MailPickerModal.vue`**
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
|
import { useMailService } from '~/services/mail'
|
|
import { useMailStore } from '~/stores/mail'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
/** ID de la tâche cible (destinataire du lien) */
|
|
taskId: number
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: boolean]
|
|
/** Émis après liaison réussie — payload = id du message lié */
|
|
linked: [messageId: number]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const mailService = useMailService()
|
|
const mailStore = useMailStore()
|
|
|
|
// ─── État ─────────────────────────────────────────────────────────────────
|
|
|
|
const searchQuery = ref('')
|
|
const allMessages = ref<MailMessageHeaderDto[]>([])
|
|
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
|
|
const isLoading = ref(false)
|
|
const isSubmitting = ref(false)
|
|
|
|
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
|
|
|
|
const filteredMessages = computed(() => {
|
|
const q = searchQuery.value.toLowerCase().trim()
|
|
if (!q) return allMessages.value
|
|
return allMessages.value.filter(
|
|
(m) =>
|
|
(m.subject ?? '').toLowerCase().includes(q) ||
|
|
(m.fromName ?? '').toLowerCase().includes(q) ||
|
|
(m.fromEmail ?? '').toLowerCase().includes(q),
|
|
)
|
|
})
|
|
|
|
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
|
|
|
|
watch(() => props.modelValue, async (open) => {
|
|
if (!open) return
|
|
searchQuery.value = ''
|
|
selectedMessage.value = null
|
|
isLoading.value = true
|
|
try {
|
|
// Utiliser les messages déjà dans le store si disponibles (dossier courant)
|
|
// Sinon charger depuis INBOX
|
|
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
|
|
const page = await mailService.listMessages(folderPath, undefined, 50)
|
|
allMessages.value = page.items
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
})
|
|
|
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
|
|
function close(): void {
|
|
emit('update:modelValue', false)
|
|
}
|
|
|
|
function selectMessage(msg: MailMessageHeaderDto): void {
|
|
selectedMessage.value = msg
|
|
}
|
|
|
|
async function handleSubmit(): Promise<void> {
|
|
if (!selectedMessage.value) return
|
|
isSubmitting.value = true
|
|
try {
|
|
await mailService.linkTask(selectedMessage.value.id, props.taskId)
|
|
emit('linked', selectedMessage.value.id)
|
|
close()
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
// ─── Formatage ────────────────────────────────────────────────────────────
|
|
|
|
function formatDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
return new Date(iso).toLocaleDateString('fr', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport v-if="modelValue" to="body">
|
|
<Transition name="mail-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<div
|
|
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
|
style="max-height: min(90vh, 640px)"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
|
<h2 class="text-base font-bold text-neutral-900">
|
|
{{ t('mail.pickerModal.title') }}
|
|
</h2>
|
|
<MalioButtonIcon
|
|
icon="mdi:close"
|
|
aria-label="Fermer"
|
|
variant="ghost"
|
|
icon-size="20"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Corps -->
|
|
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
|
<!-- Recherche locale -->
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
:placeholder="t('mail.pickerModal.searchPlaceholder')"
|
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
/>
|
|
|
|
<!-- Résultats -->
|
|
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
|
|
<!-- Chargement -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="flex items-center justify-center py-8 text-sm text-neutral-400"
|
|
>
|
|
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
|
{{ t('mail.pickerModal.loading') }}
|
|
</div>
|
|
|
|
<!-- Vide -->
|
|
<div
|
|
v-else-if="filteredMessages.length === 0"
|
|
class="py-8 text-center text-sm text-neutral-400 italic"
|
|
>
|
|
{{ t('mail.pickerModal.empty') }}
|
|
</div>
|
|
|
|
<!-- Liste -->
|
|
<button
|
|
v-for="msg in filteredMessages"
|
|
:key="msg.id"
|
|
type="button"
|
|
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
|
:class="selectedMessage?.id === msg.id
|
|
? 'bg-primary-50 border-l-2 border-primary-500'
|
|
: 'border-l-2 border-transparent'"
|
|
@click="selectMessage(msg)"
|
|
>
|
|
<Icon
|
|
name="material-symbols:mail-outline"
|
|
size="16"
|
|
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate font-medium text-neutral-800">
|
|
{{ msg.subject ?? t('mail.noSubject') }}
|
|
</p>
|
|
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
|
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
|
|
<span class="flex-shrink-0">·</span>
|
|
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
|
|
</p>
|
|
</div>
|
|
<Icon
|
|
v-if="selectedMessage?.id === msg.id"
|
|
name="material-symbols:check-circle"
|
|
size="16"
|
|
class="flex-shrink-0 text-primary-500"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
label="Annuler"
|
|
button-class="w-auto px-4"
|
|
@click="close"
|
|
/>
|
|
<MalioButton
|
|
:label="t('mail.pickerModal.submit')"
|
|
button-class="w-auto px-6"
|
|
:disabled="!selectedMessage || isSubmitting"
|
|
@click="handleSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.mail-modal-enter-active,
|
|
.mail-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-active > div:last-child,
|
|
.mail-modal-leave-active > div:last-child {
|
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
|
}
|
|
|
|
.mail-modal-enter-from,
|
|
.mail-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.mail-modal-enter-from > div:last-child {
|
|
transform: scale(0.95) translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
Points clés :
|
|
- Le filtrage est **local** (sur les 50 messages chargés) — pas de debounce nécessaire, réactif instantané.
|
|
- La note sur `linkTask(messageId, taskId)` : l'ordre des arguments est `(mailId, taskId)` dans `useMailService`. Ici on passe `selectedMessage.value.id` (mailId) et `props.taskId`.
|
|
- Utilise `mailStore.selectedFolderPath` pour charger depuis le dossier courant (meilleure UX si l'utilisateur vient de la page mail). Fallback sur `INBOX`.
|
|
|
|
- [ ] **Step 2 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailPickerModal" | head -10
|
|
```
|
|
|
|
Attendu : aucune erreur.
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailPickerModal.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailPickerModal — sélection mail depuis dossier courant, liaison taskId"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6 : Onglet "Mails" dans `TaskModal.vue`
|
|
|
|
Ajouter un troisième onglet `mails` dans `TaskModal.vue`. Cet onglet est visible uniquement pour les utilisateurs non-ROLE_CLIENT (check `authStore.user?.roles`). Il charge les mails liés via `mailService.listMailsForTask(task.id)` à l'ouverture de l'onglet, affiche une liste compacte cliquable, propose un bouton "Lier un mail" qui ouvre `MailPickerModal`.
|
|
|
|
**Important :** `TaskModal.vue` est un composant lourd. Modifier avec précision — ne pas altérer les onglets `details` et `planning` existants.
|
|
|
|
- [ ] **Step 1 : Lire le fichier actuel pour localiser les points de modification**
|
|
|
|
Lire `frontend/components/task/TaskModal.vue` pour identifier :
|
|
1. La ligne `const activeTab = ref<'details' | 'planning'>('details')` — à étendre au type union.
|
|
2. La nav `<nav class="flex gap-6">` et sa boucle `v-for="tab in ['details', 'planning']"` — à étendre.
|
|
3. La section `<div v-show="activeTab === 'planning'">` — après laquelle insérer l'onglet mails.
|
|
|
|
- [ ] **Step 2 : Modifier le type `activeTab` et ajouter les imports**
|
|
|
|
Dans la section `<script setup lang="ts">` (après le bloc de la section `<template>`), modifier la ligne :
|
|
|
|
```typescript
|
|
const activeTab = ref<'details' | 'planning'>('details')
|
|
```
|
|
|
|
en :
|
|
|
|
```typescript
|
|
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
|
```
|
|
|
|
Ajouter les imports nécessaires (après les imports existants) :
|
|
|
|
```typescript
|
|
import { useMailService } from '~/services/mail'
|
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
|
```
|
|
|
|
Ajouter les variables d'état pour l'onglet mails (après `const activeTab = ref<...>`) :
|
|
|
|
```typescript
|
|
const mailService = useMailService()
|
|
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
|
const mailsLoading = ref(false)
|
|
const showMailPickerModal = ref(false)
|
|
|
|
const isMailUser = computed(() =>
|
|
!(authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
|
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true)
|
|
)
|
|
```
|
|
|
|
Ajouter la fonction de chargement des mails :
|
|
|
|
```typescript
|
|
async function loadLinkedMails(): Promise<void> {
|
|
if (!props.task || !isMailUser.value) return
|
|
mailsLoading.value = true
|
|
try {
|
|
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
|
} catch {
|
|
linkedMails.value = []
|
|
} finally {
|
|
mailsLoading.value = false
|
|
}
|
|
}
|
|
```
|
|
|
|
Étendre le watcher existant `watch(() => props.modelValue, ...)` pour charger les mails quand l'onglet mails est actif, OU ajouter un watcher sur `activeTab` :
|
|
|
|
```typescript
|
|
watch(activeTab, async (tab) => {
|
|
if (tab === 'mails' && props.task) {
|
|
await loadLinkedMails()
|
|
}
|
|
})
|
|
```
|
|
|
|
Ajouter le handler de liaison réussie (appelé par `MailPickerModal`) :
|
|
|
|
```typescript
|
|
async function handleMailLinked(): Promise<void> {
|
|
showMailPickerModal.value = false
|
|
await loadLinkedMails()
|
|
}
|
|
```
|
|
|
|
Ajouter la fonction de formatage de date (utilisée dans l'onglet) :
|
|
|
|
```typescript
|
|
function formatMailDate(iso: string | null): string {
|
|
if (!iso) return ''
|
|
return new Date(iso).toLocaleDateString('fr', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3 : Modifier le template — nav des onglets**
|
|
|
|
Remplacer la boucle d'onglets :
|
|
|
|
```html
|
|
<button
|
|
v-for="tab in ['details', 'planning']"
|
|
:key="tab"
|
|
type="button"
|
|
class="px-1 pb-3 text-sm font-semibold transition"
|
|
:class="activeTab === tab
|
|
? 'border-b-2 border-primary-500 text-primary-500'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="activeTab = tab as 'details' | 'planning'"
|
|
>
|
|
{{ $t(`tasks.${tab}Tab`) }}
|
|
</button>
|
|
```
|
|
|
|
par :
|
|
|
|
```html
|
|
<button
|
|
v-for="tab in availableTabs"
|
|
:key="tab"
|
|
type="button"
|
|
class="px-1 pb-3 text-sm font-semibold transition"
|
|
:class="activeTab === tab
|
|
? 'border-b-2 border-primary-500 text-primary-500'
|
|
: 'text-neutral-500 hover:text-neutral-700'"
|
|
@click="activeTab = tab as 'details' | 'planning' | 'mails'"
|
|
>
|
|
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
|
|
</button>
|
|
```
|
|
|
|
Et ajouter le computed `availableTabs` dans le script :
|
|
|
|
```typescript
|
|
const availableTabs = computed(() => {
|
|
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
|
if (isEditing.value && isMailUser.value) base.push('mails')
|
|
return base
|
|
})
|
|
```
|
|
|
|
Note : l'onglet "Mails" n'apparaît qu'en mode édition (tâche existante avec ID) et uniquement pour les non-ROLE_CLIENT.
|
|
|
|
- [ ] **Step 4 : Ajouter le contenu de l'onglet mails dans le template**
|
|
|
|
Après la balise fermante `</div>` du `<div v-show="activeTab === 'planning'">`, ajouter :
|
|
|
|
```html
|
|
<!-- Onglet Mails -->
|
|
<div v-show="activeTab === 'mails'" class="space-y-4">
|
|
<!-- Chargement -->
|
|
<div v-if="mailsLoading" class="flex items-center justify-center py-8">
|
|
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
|
</div>
|
|
|
|
<!-- Vide -->
|
|
<div
|
|
v-else-if="linkedMails.length === 0"
|
|
class="flex flex-col items-center justify-center gap-3 py-8 text-center"
|
|
>
|
|
<Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
|
|
<p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
|
|
</div>
|
|
|
|
<!-- Liste mails liés -->
|
|
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
|
<NuxtLink
|
|
v-for="mail in linkedMails"
|
|
:key="mail.id"
|
|
:to="`/mail?messageId=${mail.id}`"
|
|
class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
|
|
:title="$t('mail.taskTab.openInMailer')"
|
|
>
|
|
<Icon
|
|
name="material-symbols:mail-outline"
|
|
size="16"
|
|
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate font-medium text-neutral-800">
|
|
{{ mail.subject ?? $t('mail.noSubject') }}
|
|
</p>
|
|
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
|
<span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
|
|
<span>·</span>
|
|
<span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
|
|
</p>
|
|
</div>
|
|
<Icon
|
|
name="material-symbols:open-in-new"
|
|
size="14"
|
|
class="flex-shrink-0 text-neutral-300"
|
|
/>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<!-- Bouton lier un mail -->
|
|
<div class="pt-2">
|
|
<MalioButton
|
|
:label="$t('mail.taskTab.linkButton')"
|
|
variant="secondary"
|
|
icon-name="material-symbols:link"
|
|
icon-position="left"
|
|
:icon-size="14"
|
|
button-class="w-auto"
|
|
@click="showMailPickerModal = true"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Modal picker mail -->
|
|
<MailPickerModal
|
|
v-if="task"
|
|
v-model="showMailPickerModal"
|
|
:task-id="task.id"
|
|
@linked="handleMailLinked"
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 5 : Reset de l'onglet actif à l'ouverture**
|
|
|
|
Dans le watcher existant `watch(() => props.modelValue, async (open) => { if (open) { activeTab.value = 'details' ... } })`, l'onglet est déjà resetté à `'details'` — vérifier que c'est bien le cas (ne pas modifier cette ligne).
|
|
|
|
Ajouter le reset de `linkedMails` :
|
|
|
|
```typescript
|
|
// À l'intérieur du bloc `if (open) {`, après `activeTab.value = 'details'`
|
|
linkedMails.value = []
|
|
```
|
|
|
|
- [ ] **Step 6 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep -E "TaskModal|mail" | head -20
|
|
```
|
|
|
|
Attendu : aucune nouvelle erreur.
|
|
|
|
- [ ] **Step 7 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/components/task/TaskModal.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : onglet Mails dans TaskModal — liste mails liés, bouton lier, MailPickerModal"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7 : Brancher les handlers dans `pages/mail.vue`
|
|
|
|
Remplacer les placeholders `console.warn` par l'ouverture des modals. Ajouter les refs `showCreateTaskModal` et `showLinkTaskModal` avec les states associés.
|
|
|
|
- [ ] **Step 1 : Modifier `frontend/pages/mail.vue`**
|
|
|
|
Dans la section `<script setup lang="ts">`, ajouter les imports :
|
|
|
|
```typescript
|
|
import type { Task } from '~/services/dto/task'
|
|
```
|
|
|
|
Ajouter les refs d'état des modals (après les handlers existants) :
|
|
|
|
```typescript
|
|
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
|
|
|
|
const showCreateTaskModal = ref(false)
|
|
const showLinkTaskModal = ref(false)
|
|
const activeMailIdForModal = ref<number | null>(null)
|
|
```
|
|
|
|
Remplacer les fonctions placeholder :
|
|
|
|
```typescript
|
|
// Avant (à supprimer) :
|
|
function handleCreateTask(mailId: number): void {
|
|
console.warn('[mail] handleCreateTask mailId=', mailId, '— modal à implémenter en Phase 6')
|
|
}
|
|
|
|
function handleLinkTask(mailId: number): void {
|
|
console.warn('[mail] handleLinkTask mailId=', mailId, '— modal à implémenter en Phase 6')
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// Après (à insérer à la place) :
|
|
function handleCreateTask(mailId: number): void {
|
|
activeMailIdForModal.value = mailId
|
|
showCreateTaskModal.value = true
|
|
}
|
|
|
|
function handleLinkTask(mailId: number): void {
|
|
activeMailIdForModal.value = mailId
|
|
showLinkTaskModal.value = true
|
|
}
|
|
|
|
function handleTaskCreated(task: Task): void {
|
|
showCreateTaskModal.value = false
|
|
// La tâche est créée et liée côté backend — pas d'action supplémentaire côté page mail
|
|
// Le toast de succès est déjà géré par useMailService.createTaskFromMail (toastSuccessKey)
|
|
}
|
|
|
|
function handleTaskLinked(taskId: number): void {
|
|
showLinkTaskModal.value = false
|
|
// Idem — toast géré par useMailService.linkTask
|
|
}
|
|
```
|
|
|
|
Dans le `<template>`, ajouter les modals juste avant la balise `</div>` de fermeture principale :
|
|
|
|
```html
|
|
<!-- Modal créer tâche depuis mail -->
|
|
<MailCreateTaskModal
|
|
v-if="activeMailIdForModal !== null"
|
|
v-model="showCreateTaskModal"
|
|
:message-id="activeMailIdForModal"
|
|
:message-detail="selectedMessageDetail"
|
|
@created="handleTaskCreated"
|
|
/>
|
|
|
|
<!-- Modal lier mail à tâche -->
|
|
<MailLinkTaskModal
|
|
v-if="activeMailIdForModal !== null"
|
|
v-model="showLinkTaskModal"
|
|
:message-id="activeMailIdForModal"
|
|
@linked="handleTaskLinked"
|
|
/>
|
|
```
|
|
|
|
- [ ] **Step 2 : Vérification TypeScript**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "mail.vue" | head -10
|
|
```
|
|
|
|
Attendu : aucune erreur.
|
|
|
|
- [ ] **Step 3 : Commit**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : pages/mail.vue — branche handlers Phase 6 (MailCreateTaskModal + MailLinkTaskModal)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8 : Validation TypeScript globale + tests manuels end-to-end
|
|
|
|
- [ ] **Step 1 : TypeScript strict — zéro erreur Phase 6**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1
|
|
```
|
|
|
|
Attendu : sortie vide. Erreurs fréquentes à anticiper :
|
|
|
|
- `Type '"mails"' is not assignable to type '"details" | "planning"'` → vérifier que le type union du ref est bien `'details' | 'planning' | 'mails'` ET que le cast `as 'details' | 'planning' | 'mails'` est appliqué dans le handler click de la nav.
|
|
- `Property 'id' does not exist on type 'Task | null'` → le `v-if="task"` dans le template assure que `task` est non-null dans la portée — TypeScript peut ne pas l'inférer dans le script ; utiliser `props.task?.id` ou un guard explicite.
|
|
- `Cannot find name 'MailPickerModal'` → Nuxt auto-importe les composants de `components/` — vérifier le nom de fichier (PascalCase, chemin `components/mail/MailPickerModal.vue`).
|
|
- `Module '"~/services/mail"' has no exported member 'useMailService'` → vérifier l'import dans `TaskModal.vue` ; le service est un named export.
|
|
|
|
- [ ] **Step 2 : Démarrer le serveur dev**
|
|
|
|
```bash
|
|
cd /home/r-dev/malio-dev/Lesstime && make dev-nuxt
|
|
```
|
|
|
|
Attendu : `Nuxt ready` sur `http://localhost:3002`. Aucune erreur de compilation Nuxt.
|
|
|
|
- [ ] **Step 3 : Workflow 1 — Créer une tâche depuis un mail**
|
|
|
|
- [ ] Se connecter avec `alice` / `alice` (ROLE_USER)
|
|
- [ ] Naviguer vers `http://localhost:3002/mail`
|
|
- [ ] Sélectionner un dossier et un message (si backend Phase 3 dispo) ou forcer via DevTools (injecter `selectedMessageDetail` dans le store Pinia)
|
|
- [ ] Cliquer "Créer une tâche" dans `MailMessageViewer`
|
|
- [ ] Vérifier : `MailCreateTaskModal` s'ouvre, affiche le sujet + expéditeur en lecture seule
|
|
- [ ] Sélectionner un projet (ex : le projet SIRH des fixtures)
|
|
- [ ] Optionnel : sélectionner un groupe
|
|
- [ ] Cliquer "Créer la tâche"
|
|
- [ ] Vérifier : toast succès "Tâche créée depuis le mail." + modal se ferme
|
|
- [ ] Naviguer vers le projet correspondant → la nouvelle tâche apparaît avec titre = subject du mail
|
|
- [ ] Ouvrir la tâche → onglet "Mails" → le mail source apparaît dans la liste
|
|
|
|
- [ ] **Step 4 : Workflow 2 — Lier une tâche existante à un mail**
|
|
|
|
- [ ] Sur la page `/mail`, sélectionner un message
|
|
- [ ] Cliquer "Lier à une tâche" dans `MailMessageViewer`
|
|
- [ ] Vérifier : `MailLinkTaskModal` s'ouvre
|
|
- [ ] Taper quelques lettres dans la recherche → les tâches s'affichent avec debounce
|
|
- [ ] Sélectionner une tâche (ex : "Réunion de suivi hebdomadaire" depuis les fixtures)
|
|
- [ ] Cliquer "Lier la tâche"
|
|
- [ ] Vérifier : toast succès "Mail lié à la tâche." + modal se ferme
|
|
- [ ] Ouvrir la tâche liée → onglet "Mails" → le mail apparaît dans la liste
|
|
|
|
- [ ] **Step 5 : Workflow 3 — Lier un mail depuis l'onglet Mails du TaskModal**
|
|
|
|
- [ ] Naviguer vers un projet, ouvrir une tâche via TaskModal
|
|
- [ ] Cliquer sur l'onglet "Mails" (visible uniquement si ROLE_USER/ROLE_ADMIN)
|
|
- [ ] Vérifier : l'onglet charge et affiche "Aucun mail lié" si vide
|
|
- [ ] Cliquer "Lier un mail"
|
|
- [ ] Vérifier : `MailPickerModal` s'ouvre, charge les mails du dossier courant (ou INBOX)
|
|
- [ ] Rechercher/sélectionner un mail, cliquer "Lier ce mail"
|
|
- [ ] Vérifier : toast + liste rafraîchie dans l'onglet "Mails"
|
|
|
|
- [ ] **Step 6 : Workflow 4 — Navigation depuis l'onglet Mails vers /mail**
|
|
|
|
- [ ] Dans l'onglet "Mails" du TaskModal, cliquer sur un mail lié
|
|
- [ ] Vérifier : navigation vers `/mail?messageId={id}` + le mail s'ouvre automatiquement (deep-link Phase 5)
|
|
|
|
- [ ] **Step 7 : Vérification ROLE_CLIENT**
|
|
|
|
- [ ] Se connecter avec `client-liot` / `client`
|
|
- [ ] Naviguer vers `/mail` → redirect `/portal` (Phase 5)
|
|
- [ ] Via le portal, ouvrir un ticket client → TaskModal ne doit pas afficher l'onglet "Mails" (computed `availableTabs` l'exclut pour ROLE_CLIENT)
|
|
|
|
- [ ] **Step 8 : Commit de correction si nécessaire**
|
|
|
|
```bash
|
|
git -C /home/r-dev/malio-dev/Lesstime add \
|
|
frontend/components/mail/ \
|
|
frontend/components/task/TaskModal.vue \
|
|
frontend/pages/mail.vue
|
|
git -C /home/r-dev/malio-dev/Lesstime commit -m "fix(mail) : corrections post-test Phase 6 (TypeScript + comportement modals)"
|
|
```
|
|
|
|
---
|
|
|
|
## Exigences techniques
|
|
|
|
- `<script setup lang="ts">` partout — aucun Options API
|
|
- **4 espaces d'indentation** — convention Lesstime
|
|
- **TypeScript strict** : 0 erreur `npx tsc --noEmit` à la fin de chaque tâche
|
|
- **Modals** : pattern `<Teleport to="body">` + `<Transition name="mail-modal">` + backdrop click (identique à `TaskModal.vue`)
|
|
- **`MailCreateTaskInput`** : `priority` est transmis en IRI `/api/task_priorities/{id}` (string), pas en id numérique — conforme au pattern existant dans `TaskModal.vue`
|
|
- **Autocomplete** : debounce 300ms dans `MailLinkTaskModal` — timer nettoyé dans `onBeforeUnmount`
|
|
- **Filtrage mail** : local dans `MailPickerModal` (liste chargée une fois, filtrée en computed) — pas d'appel API par frappe
|
|
- **ROLE_CLIENT** : l'onglet "Mails" dans `TaskModal` est exclu via `availableTabs` computed — double protection (backend bloque déjà les endpoints mail pour ROLE_CLIENT)
|
|
- **Toast** : géré automatiquement par `useApi` via `toastSuccessKey` dans les méthodes du service — ne pas dupliquer dans les composants
|
|
- **NuxtLink** pour la navigation vers `/mail?messageId=X` depuis l'onglet "Mails" (pas de `router.push`) — meilleure accessibilité
|
|
- **`v-if="task"` dans TaskModal** : utiliser `props.task` (non null en mode édition) avec guard explicite pour les appels service
|
|
- **Format commits** : `feat(mail) : <message>` (espace avant `:`), 1 commit par composant/fichier principal
|
|
- **Pas de `console.warn`** dans le code final — supprimer les placeholders Phase 5
|
|
|
|
---
|
|
|
|
## Fichiers créés/modifiés — récapitulatif
|
|
|
|
| Fichier | Type | Action |
|
|
|---|---|---|
|
|
| `frontend/components/mail/MailCreateTaskModal.vue` | Composant Vue | Créer |
|
|
| `frontend/components/mail/MailLinkTaskModal.vue` | Composant Vue | Créer |
|
|
| `frontend/components/mail/MailPickerModal.vue` | Composant Vue | Créer |
|
|
| `frontend/components/task/TaskModal.vue` | Composant Vue | Modifier (onglet mails, state, watcher) |
|
|
| `frontend/pages/mail.vue` | Page Nuxt | Modifier (handlers + modals dans template) |
|
|
| `frontend/i18n/locales/fr.json` | i18n | Modifier (sous-clés `mail.createTaskModal`, `mail.linkTaskModal`, `mail.pickerModal`, `mail.taskTab`) |
|
|
| `frontend/i18n/locales/en.json` | i18n | Modifier si existant |
|
|
|
|
**Fichiers Phase 4/5 utilisés (ne pas modifier sauf indication) :**
|
|
|
|
| Fichier | Utilisation dans Phase 6 |
|
|
|---|---|
|
|
| `frontend/services/mail.ts` | `createTaskFromMail`, `linkTask`, `listMailsForTask` |
|
|
| `frontend/services/dto/mail.ts` | `MailMessageDetailDto`, `MailMessageHeaderDto`, `MailCreateTaskInput` |
|
|
| `frontend/services/tasks.ts` | `getFiltered()` dans `MailLinkTaskModal` |
|
|
| `frontend/services/projects.ts` | `getAll()` dans `MailCreateTaskModal`, `MailLinkTaskModal` |
|
|
| `frontend/services/task-groups.ts` | `getByProject()` dans `MailCreateTaskModal` |
|
|
| `frontend/services/task-priorities.ts` | `getAll()` dans `MailCreateTaskModal` |
|
|
| `frontend/stores/mail.ts` | `selectedFolderPath` dans `MailPickerModal` |
|
|
| `frontend/components/mail/MailMessageViewer.vue` | Émet `createTask` / `linkTask` (déjà câblé en Phase 5) |
|
|
|
|
---
|
|
|
|
## Output attendu — critères d'acceptation Phase 6
|
|
|
|
| Critère | Vérification |
|
|
|---|---|
|
|
| TypeScript strict : 0 erreur | `cd frontend && npx tsc --noEmit` → sortie vide |
|
|
| Workflow 1 complet | Mail → "Créer tâche" → tâche créée avec subject comme titre → visible dans onglet Mails du TaskModal |
|
|
| Workflow 2 complet | Mail → "Lier à une tâche" → sélection + liaison → mail visible dans onglet Mails |
|
|
| Workflow 3 complet | TaskModal onglet Mails → "Lier un mail" → MailPickerModal → liaison → liste rafraîchie |
|
|
| Workflow 4 complet | Onglet Mails → click mail → navigation `/mail?messageId=X` → deep-link fonctionne |
|
|
| ROLE_CLIENT isolé | Onglet "Mails" absent pour ROLE_CLIENT (computed `availableTabs`) |
|
|
| Aucun `console.warn` résiduel | DevTools console → aucun warn `[mail]` Phase 5 |
|
|
| Pas d'erreur Vue/Nuxt | DevTools console → aucune erreur rouge |
|
|
|
|
La Phase 7 (Admin config + sidebar + polish) peut commencer dès que tous ces critères sont satisfaits.
|