Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bffff9b83 | ||
| d7af8ee138 | |||
|
|
eb2adc9fdc | ||
|
|
4775cbf184 | ||
|
|
8be96bce0c | ||
|
|
fb97b8d4e3 |
@@ -101,7 +101,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Middleware global `auth.global.ts` protège les routes
|
- Middleware global `auth.global.ts` protège les routes
|
||||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||||
- 4 espaces d'indentation
|
- 4 espaces d'indentation
|
||||||
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
|
- MalioSelect : options `{ label: string, value: string | number | null }` — supporte les valeurs `string` (donc les enums string comme `StatusCategory`), pas seulement `number`. Vérifié dans la source `@malio/layer-ui` (`Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis)
|
||||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ framework:
|
|||||||
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
||||||
|
|
||||||
routing:
|
routing:
|
||||||
'App\Message\MailSyncRequested': async
|
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
|
||||||
|
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
|
||||||
|
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||||
|
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||||
|
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||||
|
'App\Message\MailSyncRequested': sync
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.1'
|
app.version: '0.4.4'
|
||||||
|
|||||||
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal file
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal file
@@ -0,0 +1,988 @@
|
|||||||
|
# Correctifs UI workflow — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommandé) ou superpowers:executing-plans pour exécuter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`).
|
||||||
|
|
||||||
|
**Goal:** Corriger les régressions UI introduites par les workflows (D&D, sélecteur de statut, cartes, couleurs) et améliorer l'UX mail/modales, sur la base de `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`.
|
||||||
|
|
||||||
|
**Architecture:** Une brique partagée (filtrage des statuts par workflow + palette de catégories + composant modale réutilisable) consommée par les autres chantiers. Backend modifié uniquement pour l'endpoint `create-task` (#6). Correction de données prod (#4) via migration Doctrine.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine (backend, PHPUnit) ; Nuxt 4 / Vue 3 / Pinia / Tailwind / `@malio/layer-ui` (frontend).
|
||||||
|
|
||||||
|
> **Note testing (importante).** Lesstime **n'a pas de test runner frontend** (vérifié : pas de vitest/jest dans `frontend/package.json`). La discipline TDD ne s'applique donc qu'au **backend** (PHPUnit via `make test`). Pour le **frontend**, chaque tâche se vérifie par : (1) `npm run build:dist` qui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP sur `http://localhost:8082` (DOM/visuel). **Toujours hard-reload sans cache** après build (le navigateur cache les chunks JS hashés). Login dev avec données prod importées : `Matthieu` / `admin`.
|
||||||
|
|
||||||
|
> **Branche.** Créer une branche d'implémentation depuis `develop` (ex. `fix/workflow-ui-fixes`) avant de commencer. Commits fréquents, format `<type>(<scope>) : <message>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordre d'exécution (dépendances)
|
||||||
|
|
||||||
|
1. **Task 1** — Brique front : palette de catégories + helper contraste (`#4b`, réutilisé par #1)
|
||||||
|
2. **Task 2** — Composant `AppModal` réutilisable (`#7`)
|
||||||
|
3. **Task 3** — Filtrage du sélecteur de statut par workflow dans TaskModal (`#2`)
|
||||||
|
4. **Task 4** — Drag & drop dans « Mes tâches » + entêtes teintées (`#1` + `#4b`)
|
||||||
|
5. **Task 5** — Backend : endpoint `create-task` (statut + assigné, sans priorité) (`#6` back)
|
||||||
|
6. **Task 6** — Frontend : modale de création depuis mail (`#6` front, sur AppModal)
|
||||||
|
7. **Task 7** — Suppression du bouton « Lier un mail » (`#5`)
|
||||||
|
8. **Task 8** — Cartes responsive (`#3`)
|
||||||
|
9. **Task 9** — Couleurs par défaut par catégorie + migration data prod (`#4a` + `#4c`)
|
||||||
|
10. **Task 10** — Migration de TaskModal vers AppModal (`#7`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Palette de catégories + helper de contraste
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/dto/workflow.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Ajouter la palette et le helper de contraste**
|
||||||
|
|
||||||
|
Dans `frontend/services/dto/workflow.ts`, après `STATUS_CATEGORY_LABEL` (l.5-11), ajouter :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||||
|
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||||
|
todo: '#222783',
|
||||||
|
in_progress: '#4A90D9',
|
||||||
|
blocked: '#C62828',
|
||||||
|
review: '#FF8F00',
|
||||||
|
done: '#26A69A',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||||
|
export function contrastText(hex: string): string {
|
||||||
|
const c = hex.replace('#', '')
|
||||||
|
const r = parseInt(c.slice(0, 2), 16)
|
||||||
|
const g = parseInt(c.slice(2, 4), 16)
|
||||||
|
const b = parseInt(c.slice(4, 6), 16)
|
||||||
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier le build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected: exit 0, aucune erreur TypeScript.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/services/dto/workflow.ts
|
||||||
|
git commit -m "feat(workflow) : palette de catégories canonique + helper de contraste"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Composant modale réutilisable `AppModal` (#7)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/components/ui/AppModal.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer `AppModal.vue`**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
/** Largeur max du panneau */
|
||||||
|
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
}>(), {
|
||||||
|
title: '',
|
||||||
|
width: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="app-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 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||||
|
:class="WIDTH_CLASS[width]"
|
||||||
|
>
|
||||||
|
<!-- Header (fixe) -->
|
||||||
|
<div class="flex shrink-0 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">
|
||||||
|
<slot name="title">{{ title }}</slot>
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body (scrollable) -->
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer (sticky) -->
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-modal-enter-active,
|
||||||
|
.app-modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.app-modal-enter-active > div:last-child,
|
||||||
|
.app-modal-leave-active > div:last-child {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.app-modal-enter-from,
|
||||||
|
.app-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.app-modal-enter-from > div:last-child {
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/ui/AppModal.vue
|
||||||
|
git commit -m "feat(ui) : composant AppModal réutilisable (header fixe / body scrollable / footer sticky)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> AppModal sera consommé par MailCreateTaskModal (Task 6) et TaskModal (Task 10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Filtrer le sélecteur de statut par workflow dans TaskModal (#2)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/task/TaskModal.vue` (statusOptions ~l.674-676)
|
||||||
|
|
||||||
|
**Contexte vérifié :** TaskModal reçoit déjà `:projects` (`Project[]` avec `workflow.statuses`). Le projet effectif est `showProjectSelect ? form.projectId : props.projectId` (cf. l.717). `props.statuses` (global) devient un fallback.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Remplacer `statusOptions`**
|
||||||
|
|
||||||
|
Remplacer (l.674-676) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const statusOptions = computed(() =>
|
||||||
|
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
par :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const effectiveProjectId = computed(() =>
|
||||||
|
showProjectSelect.value ? form.projectId : props.projectId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
const project = props.projects?.find(p => p.id === effectiveProjectId.value)
|
||||||
|
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||||
|
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
// Garder le statut courant s'il n'appartient pas (plus) au workflow, pour ne pas le perdre.
|
||||||
|
const current = props.task?.status
|
||||||
|
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||||
|
opts.unshift({ label: current.label, value: current.id })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si une variable `effectiveProjectId`/`activeProjectId` existe déjà (vérifier autour de l.717), réutiliser celle-ci au lieu d'en redéclarer une.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
1. Hard-reload `http://localhost:8082` (cache ignoré), login `Matthieu`/`admin`.
|
||||||
|
2. Ouvrir une tâche d'un projet **Standard** (ex. `LST-49` via « Mes tâches »).
|
||||||
|
3. Ouvrir le sélecteur « Statut ».
|
||||||
|
Expected : **5 options** (les statuts du workflow Standard) — plus aucun statut ERP (« Prêt à dev », « En dev », « Mergé », « Validation client », « Validé prod », « Abandonné »).
|
||||||
|
4. Ouvrir une tâche du projet **STARSEED** (workflow ERP, code `ERP-…`).
|
||||||
|
Expected : uniquement les statuts ERP.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/task/TaskModal.vue
|
||||||
|
git commit -m "fix(task) : sélecteur de statut filtré par le workflow du projet"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Drag & drop « Mes tâches » + entêtes teintées (#1 + #4b)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/components/task/StatusPickerPopover.vue`
|
||||||
|
- Modify: `frontend/pages/my-tasks.vue` (template kanban ~l.394-424 ; script ~l.118-140)
|
||||||
|
- Modify: `frontend/services/tasks.ts` (réutiliser `update()` existant)
|
||||||
|
|
||||||
|
**Contexte vérifié :** `TaskCard.vue` pose déjà `dataTransfer.setData('text/plain', task.id)` au `dragstart`. `my-tasks.vue` n'a **aucun** handler `@drop`/`@dragover`. Les colonnes itèrent sur `CATEGORIES` (l.119). `tasks.value` contient les tâches affichées. `tasks.ts` expose `update(id, payload: Partial<TaskWrite>)` → `PATCH /tasks/{id}` ; le statut s'écrit en IRI (`status: '/api/task_statuses/{id}'`, cf. TaskModal l.1070).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer le popover de désambiguïsation**
|
||||||
|
|
||||||
|
`frontend/components/task/StatusPickerPopover.vue` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus } from '~/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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Ajouter la logique de drop dans `my-tasks.vue` (script)**
|
||||||
|
|
||||||
|
Dans `<script setup>`, ajouter les imports et l'état (près des helpers kanban, l.118+) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
|
||||||
|
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||||
|
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||||
|
|
||||||
|
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||||
|
const wf = task.project?.workflow
|
||||||
|
if (!wf) return []
|
||||||
|
return wf.statuses.filter(s => s.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||||
|
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||||
|
await loadTasks() // recharge la liste (utiliser la fonction de rechargement existante)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||||
|
dragOverCategory.value = null
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const candidates = statusesForTaskCategory(task, category)
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
toast.error(t('myTasks.dropRefused')) // 0 statut dans cette catégorie pour ce workflow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
void applyStatus(task, candidates[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// ≥2 : popover de choix ancré au point de drop
|
||||||
|
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerChoice(status: TaskStatus): void {
|
||||||
|
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||||
|
pendingPicker.value = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Adapter `loadTasks()` / `toast` / `t` aux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dans `my-tasks.vue`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Brancher le template kanban (#1) + entêtes teintées (#4b)**
|
||||||
|
|
||||||
|
Remplacer le bloc colonne (l.397-404) par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div
|
||||||
|
v-for="cat in CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||||
|
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||||
|
@dragover.prevent="dragOverCategory = cat"
|
||||||
|
@dragleave="dragOverCategory = null"
|
||||||
|
@drop="onDrop(cat, $event)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||||
|
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||||
|
>
|
||||||
|
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis, juste avant la fermeture du `<template>` (à côté de la TaskModal), ajouter le popover :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StatusPickerPopover
|
||||||
|
v-if="pendingPicker"
|
||||||
|
:statuses="pendingPicker.statuses"
|
||||||
|
:x="pendingPicker.x"
|
||||||
|
:y="pendingPicker.y"
|
||||||
|
@pick="onPickerChoice"
|
||||||
|
@cancel="pendingPicker = null"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Ajouter la clé i18n `myTasks.dropRefused`**
|
||||||
|
|
||||||
|
Dans `frontend/i18n/locales/fr.json` (et les autres locales présentes), sous `myTasks` : `"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected: exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
1. Hard-reload, login, aller à « Mes tâches » (vue kanban).
|
||||||
|
Expected : entêtes de colonnes **colorées** (todo indigo, in_progress bleu, blocked rouge, review ambre **texte foncé**, done sarcelle).
|
||||||
|
2. Glisser une carte d'un projet **Standard** de « À faire » vers « En cours ».
|
||||||
|
Expected : le statut passe à « En cours » (1 seul statut in_progress → direct), la carte se déplace.
|
||||||
|
3. Glisser une carte du projet **STARSEED** (workflow ERP) vers « En validation » (la catégorie `review` a ≥2 statuts ERP : En review, Mergé, Validation client).
|
||||||
|
Expected : **popover** au point de drop listant ces statuts ; le choix applique le statut.
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/task/StatusPickerPopover.vue frontend/pages/my-tasks.vue frontend/i18n/locales/
|
||||||
|
git commit -m "fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : Backend `create-task` — statut + assigné, sans priorité (#6 back)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Controller/Mail/MailCreateTaskController.php`
|
||||||
|
- Test: `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php`
|
||||||
|
|
||||||
|
**Contexte vérifié :** `Task::setStatus(?TaskStatus)`, `Task::setAssignee(?User)` existent. `Project::getWorkflow()` ; `Workflow::getStatuses()` est ordonné `position ASC`. Accès mail = ROLE_USER/ROLE_ADMIN (cf. `MailAccessChecker`).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Écrire le test fonctionnel (TDD) — assigné + statut, priorité ignorée**
|
||||||
|
|
||||||
|
Ajouter dans `MailTaskIntegrationControllerTest.php` (crée ses prérequis via l'EntityManager) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$container = static::getContainer();
|
||||||
|
$em = $container->get('doctrine.orm.entity_manager');
|
||||||
|
|
||||||
|
$admin = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']);
|
||||||
|
$client->loginUser($admin);
|
||||||
|
|
||||||
|
// Projet existant (fixtures) + son workflow / premier statut + un message mail existant
|
||||||
|
$project = $em->getRepository(\App\Entity\Project::class)->findOneBy([]);
|
||||||
|
self::assertNotNull($project, 'Au moins un projet doit exister dans les fixtures');
|
||||||
|
$status = $project->getWorkflow()->getStatuses()->first();
|
||||||
|
$message = $em->getRepository(\App\Entity\MailMessage::class)->findOneBy([]);
|
||||||
|
self::assertNotNull($message, 'Au moins un message mail doit exister (fixtures ou sync)');
|
||||||
|
|
||||||
|
$client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/mail/messages/'.$message->getId().'/create-task',
|
||||||
|
[], [], ['CONTENT_TYPE' => 'application/json'],
|
||||||
|
json_encode([
|
||||||
|
'projectId' => $project->getId(),
|
||||||
|
'assigneeId' => $admin->getId(),
|
||||||
|
'statusId' => $status->getId(),
|
||||||
|
'priorityId' => 999, // doit être ignoré
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$payload = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
$task = $em->getRepository(\App\Entity\Task::class)->find($payload['taskId']);
|
||||||
|
self::assertSame($status->getId(), $task->getStatus()?->getId());
|
||||||
|
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
|
||||||
|
self::assertNull($task->getPriority(), 'priorityId ne doit plus être pris en compte');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si les fixtures ne contiennent pas de `MailMessage`, créer dans le test un `MailConfiguration` + `MailFolder` + `MailMessage` minimal via l'EM (adapter aux champs requis des entités), ou charger un dump mail. Le test échoue tant que le contrôleur n'est pas modifié.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer le test (doit échouer)**
|
||||||
|
|
||||||
|
Run: `make test` (ou `docker exec php-lesstime-fpm php bin/phpunit --filter testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority`)
|
||||||
|
Expected : FAIL (assignee/status non appliqués, priorityId encore lu).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Modifier le contrôleur**
|
||||||
|
|
||||||
|
Dans `MailCreateTaskController.php` :
|
||||||
|
|
||||||
|
a) Remplacer l'import `use App\Entity\TaskPriority;` par :
|
||||||
|
```php
|
||||||
|
use App\Entity\TaskStatus;
|
||||||
|
use App\Entity\User;
|
||||||
|
```
|
||||||
|
|
||||||
|
b) Dans la transaction (l.62-96), **remplacer** le bloc priorité (l.77-82) par l'assigné + le statut :
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
|
||||||
|
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
|
||||||
|
if (null !== $assignee) {
|
||||||
|
$task->setAssignee($assignee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
|
||||||
|
$status = null;
|
||||||
|
if (isset($body['statusId']) && null !== $body['statusId']) {
|
||||||
|
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
|
||||||
|
}
|
||||||
|
if (null === $status) {
|
||||||
|
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
|
||||||
|
}
|
||||||
|
if (null !== $status) {
|
||||||
|
$task->setStatus($status);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Lancer le test (doit passer)**
|
||||||
|
|
||||||
|
Run: `make test`
|
||||||
|
Expected : PASS. Lancer aussi `make php-cs-fixer-allow-risky`.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/Mail/MailCreateTaskController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
|
||||||
|
git commit -m "feat(mail) : create-task applique statut + assigné, retire la priorité"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Modale de création depuis un mail (#6 front)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/mail/MailCreateTaskModal.vue`
|
||||||
|
- Modify: `frontend/services/mail.ts` (`createTaskFromMail`, ~l.184-192)
|
||||||
|
- Modify: `frontend/i18n/locales/*.json` (libellés user/statut)
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Adapter le service `createTaskFromMail`**
|
||||||
|
|
||||||
|
Dans `frontend/services/mail.ts`, modifier le payload de `createTaskFromMail` : retirer `priority`, accepter `assigneeId?: number` et `statusId?: number`. Le corps POST devient :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
taskGroupId,
|
||||||
|
assigneeId,
|
||||||
|
statusId,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(adapter la signature TypeScript de la fonction en conséquence ; supprimer toute référence à `priority`).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Réécrire `MailCreateTaskModal.vue` sur AppModal + user + statut**
|
||||||
|
|
||||||
|
Remplacer le `<script setup>` : retirer `useTaskPriorityService`/`priorities`/`priorityId`/`priorityOptions`, ajouter le service users, le service statuts par workflow, et l'état `assigneeId` / `statusId`.
|
||||||
|
|
||||||
|
```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 { useMailService } from '~/services/mail'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
messageId: number
|
||||||
|
messageDetail: MailMessageDetailDto | null
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [task: Task] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const mailService = useMailService()
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const taskGroupService = useTaskGroupService()
|
||||||
|
const userService = useUserService()
|
||||||
|
|
||||||
|
const projectId = ref<number | null>(null)
|
||||||
|
const taskGroupId = ref<number | null>(null)
|
||||||
|
const assigneeId = ref<number | null>(null)
|
||||||
|
const statusId = ref<number | null>(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const touchedProject = ref(false)
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const groups = ref<TaskGroup[]>([])
|
||||||
|
const users = ref<{ id: number, username: string }[]>([])
|
||||||
|
const loadingGroups = ref(false)
|
||||||
|
|
||||||
|
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||||
|
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||||
|
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||||
|
|
||||||
|
// Statuts filtrés par le workflow du projet sélectionné (#2 réutilisé)
|
||||||
|
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||||
|
const statusOptions = computed(() =>
|
||||||
|
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [projs, us] = await Promise.all([
|
||||||
|
projectService.getAll({ archived: false }),
|
||||||
|
userService.getAll(),
|
||||||
|
])
|
||||||
|
projects.value = projs
|
||||||
|
users.value = us
|
||||||
|
})
|
||||||
|
|
||||||
|
// Au changement de projet : recharger les groupes + présélectionner le 1er statut du workflow
|
||||||
|
watch(projectId, async (pid) => {
|
||||||
|
taskGroupId.value = null
|
||||||
|
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||||
|
groups.value = []
|
||||||
|
if (!pid) return
|
||||||
|
loadingGroups.value = true
|
||||||
|
try {
|
||||||
|
groups.value = await taskGroupService.getByProject(pid)
|
||||||
|
} finally {
|
||||||
|
loadingGroups.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset + user par défaut = utilisateur connecté
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
projectId.value = null
|
||||||
|
taskGroupId.value = null
|
||||||
|
statusId.value = null
|
||||||
|
assigneeId.value = auth.user?.id ?? null
|
||||||
|
touchedProject.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
assigneeId: assigneeId.value ?? undefined,
|
||||||
|
statusId: statusId.value ?? undefined,
|
||||||
|
})
|
||||||
|
emit('created', task)
|
||||||
|
close()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis remplacer tout le `<template>` (et le `<style>` devient inutile — AppModal gère l'animation) par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<AppModal :model-value="modelValue" width="lg" :title="t('mail.createTaskModal.title')" @update:model-value="emit('update:modelValue', $event)">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||||
|
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||||
|
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||||
|
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||||
|
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-if="projectId">
|
||||||
|
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<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" />
|
||||||
|
</template>
|
||||||
|
</AppModal>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Ajouter les clés i18n**
|
||||||
|
|
||||||
|
Dans `mail.createTaskModal` (toutes les locales) : `statusLabel` (« Statut »), `assigneeLabel` (« Assigné à »), `assigneePlaceholder` (« Aucun »). Retirer `priorityLabel`/`priorityPlaceholder` si plus utilisées ailleurs.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected : exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
1. Hard-reload, login, Messagerie → ouvrir un message → « Créer une tâche ».
|
||||||
|
Expected : modale **élargie**, footer **toujours visible**, champs = Projet / Groupe / **Statut** / **Assigné** (défaut = Matthieu). Plus de champ Priorité.
|
||||||
|
2. Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
|
||||||
|
3. Créer la tâche → succès, tâche liée au mail avec le bon statut/assigné.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/mail/MailCreateTaskModal.vue frontend/services/mail.ts frontend/i18n/locales/
|
||||||
|
git commit -m "feat(mail) : création de tâche depuis mail — sélecteur user + statut (workflow), modale agrandie"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 : Supprimer le bouton « Lier un mail » (#5)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/task/TaskModal.vue` (bouton ~l.487-493 ; `<MailPickerModal>` ~l.498-503 ; état `showMailPickerModal` l.627 ; `handleMailLinked` ~l.936-938)
|
||||||
|
- Delete: `frontend/components/mail/MailPickerModal.vue`
|
||||||
|
- Modify: `frontend/i18n/locales/*.json` (clé `mail.taskTab.linkButton`)
|
||||||
|
|
||||||
|
**Contexte vérifié :** `MailPickerModal` n'est utilisé **que** par TaskModal.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Retirer le bouton, la modale, l'état et le handler dans TaskModal.vue**
|
||||||
|
|
||||||
|
- Supprimer le `<MalioButton ... :label="$t('mail.taskTab.linkButton')" ... @click="showMailPickerModal = true" />` (~l.487-493).
|
||||||
|
- Supprimer le bloc `<MailPickerModal ... v-model="showMailPickerModal" ... @linked="handleMailLinked" />` (~l.498-503).
|
||||||
|
- Supprimer `const showMailPickerModal = ref(false)` (l.627).
|
||||||
|
- Supprimer la fonction `handleMailLinked` (~l.936-938).
|
||||||
|
- Retirer l'éventuel `import MailPickerModal` (si import explicite ; sinon auto-import, rien à faire).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Supprimer le composant et la clé i18n**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm frontend/components/mail/MailPickerModal.vue
|
||||||
|
```
|
||||||
|
Retirer la clé `mail.taskTab.linkButton` dans toutes les locales (vérifier qu'elle n'est plus référencée : `grep -rn "taskTab.linkButton" frontend/`).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected : exit 0, aucune référence cassée.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
Ouvrir une tâche → onglet « Mails ».
|
||||||
|
Expected : plus de bouton « Lier un mail ». La liste des mails liés et le bouton de suppression de lien (s'il existe) restent fonctionnels.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A frontend/components/task/TaskModal.vue frontend/i18n/locales/
|
||||||
|
git commit -m "refactor(task) : suppression du bouton « Lier un mail » et de MailPickerModal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 : Cartes responsive (#3)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/task/TaskCard.vue` (ligne badges ~l.42-106)
|
||||||
|
|
||||||
|
**Contexte vérifié :** badges en `rounded-full px-2 py-0.5 ... text-white` sans contrainte ; conteneur `mt-2 flex items-center gap-1.5` sans `min-w-0` ni `flex-wrap`. Décision : **2-3 tags max + « +N »**, hauteur fixe, troncature.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Titre — `line-clamp-2`**
|
||||||
|
|
||||||
|
Ligne 30, remplacer :
|
||||||
|
```vue
|
||||||
|
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
|
```
|
||||||
|
par :
|
||||||
|
```vue
|
||||||
|
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Conteneur badges — `min-w-0` + troncature des badges**
|
||||||
|
|
||||||
|
Sur le conteneur (l.42) ajouter `min-w-0` : `class="mt-2 flex min-w-0 items-center gap-1.5"`.
|
||||||
|
|
||||||
|
Sur les badges statut/priorité/tag/deadline, ajouter `max-w-[7rem] truncate shrink-0` à la classe `rounded-full ...`. Exemple pour le statut (l.45) :
|
||||||
|
```vue
|
||||||
|
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Limiter les tags à 2-3 + badge « +N »**
|
||||||
|
|
||||||
|
Remplacer la boucle des tags (l.57-64) par :
|
||||||
|
```vue
|
||||||
|
<span
|
||||||
|
v-for="tag in task.tags.slice(0, 2)"
|
||||||
|
:key="tag.id"
|
||||||
|
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: tag.color }"
|
||||||
|
:title="tag.label"
|
||||||
|
>
|
||||||
|
{{ tag.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="task.tags.length > 2"
|
||||||
|
class="shrink-0 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600"
|
||||||
|
:title="task.tags.slice(2).map(t => t.label).join(', ')"
|
||||||
|
>
|
||||||
|
+{{ task.tags.length - 2 }}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected : exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
Sur « Mes tâches » avec données prod (cartes à nombreux tags) : vérifier via le DOM qu'aucune carte ne déborde (mesurer `scrollWidth - clientWidth` ≤ 1 sur la ligne de badges) ; les cartes à >2 tags montrent un badge « +N » ; titres longs tronqués sur 2 lignes.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/task/TaskCard.vue
|
||||||
|
git commit -m "fix(task) : cartes responsive — troncature badges, max 2 tags + « +N », titre line-clamp"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9 : Couleurs par défaut par catégorie + migration data prod (#4a + #4c)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/admin/WorkflowDrawer.vue` (`addStatus`, l.172-180 ; `categoryOptions` l.143-151)
|
||||||
|
- Create: `migrations/VersionYYYYMMDDHHMMSS.php`
|
||||||
|
- Modify: `src/DataFixtures/AppFixtures.php` (déjà correct — vérifier, ne rien changer si OK)
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Couleur par défaut par catégorie dans `addStatus` (front)**
|
||||||
|
|
||||||
|
Dans `WorkflowDrawer.vue`, importer la palette et l'utiliser à la création :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
function addStatus() {
|
||||||
|
form.statuses.push({
|
||||||
|
label: '',
|
||||||
|
color: STATUS_CATEGORY_COLOR.todo, // défaut cohérent (catégorie initiale = todo)
|
||||||
|
position: form.statuses.length,
|
||||||
|
isFinal: false,
|
||||||
|
category: 'todo',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Et, pour aligner la couleur quand l'utilisateur change la catégorie d'un statut, ajouter un watcher dans le `<script setup>` :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { StatusCategory } from '~/services/dto/workflow'
|
||||||
|
// (déjà importé pour le type ; sinon ajouter)
|
||||||
|
|
||||||
|
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||||
|
cats.forEach((cat, i) => {
|
||||||
|
// si la catégorie vient de changer ET que la couleur correspond encore au défaut de l'ancienne catégorie, réaligner
|
||||||
|
if (prev && cat !== prev[i] && form.statuses[i] && form.statuses[i].color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||||
|
form.statuses[i].color = STATUS_CATEGORY_COLOR[cat]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: false })
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ce watcher ne réécrase **pas** une couleur personnalisée (il n'agit que si la couleur courante = défaut de l'ancienne catégorie).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Build + vérif front**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist` (exit 0). Vérifier en navigateur : ajouter un statut → couleur par défaut indigo ; changer sa catégorie vers « En cours » alors qu'il a la couleur par défaut → la couleur passe au bleu `#4A90D9`.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Générer la migration de correction data**
|
||||||
|
|
||||||
|
Run: `make shell` puis `php bin/console make:migration` **n'est pas adapté** (pas de diff de schéma). Créer manuellement `migrations/VersionYYYYMMDDHHMMSS.php` (timestamp courant) :
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Remet les couleurs classiques sur les statuts du workflow Standard (dérive data prod #4).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Cible : statuts du workflow nommé "Standard", par catégorie. Ne touche pas aux autres workflows.
|
||||||
|
$map = [
|
||||||
|
'todo' => '#222783',
|
||||||
|
'in_progress' => '#4A90D9',
|
||||||
|
'blocked' => '#C62828',
|
||||||
|
'review' => '#FF8F00',
|
||||||
|
'done' => '#26A69A',
|
||||||
|
];
|
||||||
|
foreach ($map as $category => $hex) {
|
||||||
|
$this->addSql(
|
||||||
|
"UPDATE task_status SET color = :hex
|
||||||
|
WHERE category = :cat
|
||||||
|
AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)",
|
||||||
|
['hex' => $hex, 'cat' => $category]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Pas de rollback des couleurs (correction one-shot).
|
||||||
|
$this->throwIrreversibleMigration('Correction de couleurs non réversible.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Vérifier la signature `addSql` avec paramètres nommés de la version Doctrine Migrations utilisée ; sinon utiliser des valeurs inline (couleurs et catégories sont des constantes sûres). Confirmer le nom de colonne `workflow_id` via `\d task_status`.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Tester la migration en local (sur données prod importées)**
|
||||||
|
|
||||||
|
Run: `make migration-migrate`
|
||||||
|
Puis vérifier :
|
||||||
|
```bash
|
||||||
|
docker exec -e PGPASSWORD=root lesstime-db-1 psql -U root -p 5435 -d lesstime -c "select label,color from task_status ts join workflow w on w.id=ts.workflow_id where w.name='Standard' order by ts.position;"
|
||||||
|
```
|
||||||
|
Expected : `#222783 / #4A90D9 / #C62828 / #FF8F00 / #26A69A`.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Vérif navigateur**
|
||||||
|
|
||||||
|
Kanban d'un projet Standard + badges de cartes : couleurs classiques de retour.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/admin/WorkflowDrawer.vue migrations/
|
||||||
|
git commit -m "fix(workflow) : couleurs par défaut par catégorie + migration de correction du workflow Standard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10 : Migrer TaskModal vers AppModal (#7)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/task/TaskModal.vue` (coque de la modale uniquement : Teleport/Transition/overlay + header + footer)
|
||||||
|
|
||||||
|
> À faire en dernier car TaskModal est touché par #2 et #5 ; on stabilise d'abord son contenu. La migration ne change que la **coque** (structure header/body/footer), pas la logique métier.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Remplacer la coque par AppModal**
|
||||||
|
|
||||||
|
Envelopper le contenu existant dans `<AppModal :model-value="isOpen" width="lg" @update:model-value="isOpen = $event">`, déplacer le titre dans le slot `#title` (ou prop `title`), placer le corps actuel dans le slot par défaut et la barre d'actions (Supprimer / Annuler / Enregistrer, ~l.507-549) dans `<template #footer>`. Retirer le `Teleport`/`Transition`/overlay et le `max-h`/`overflow` manuels désormais gérés par AppModal.
|
||||||
|
|
||||||
|
> Conserver tels quels les sous-modales internes (ConfirmDeleteTaskModal, etc.) et la logique `close()` (qui bloque la fermeture si une confirmation est ouverte) — la connecter au `@update:model-value` d'AppModal.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Build**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build:dist`
|
||||||
|
Expected : exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||||
|
|
||||||
|
Ouvrir une tâche avec beaucoup de contenu (description longue) sur un viewport normal.
|
||||||
|
Expected : header et **footer (Supprimer/Annuler/Enregistrer) toujours visibles**, body scrollable au milieu. Mesurer que le bouton « Enregistrer » est dans le viewport (`getBoundingClientRect().bottom <= window.innerHeight`).
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/components/task/TaskModal.vue
|
||||||
|
git commit -m "refactor(task) : TaskModal migré sur AppModal (footer sticky)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review — couverture spec
|
||||||
|
|
||||||
|
| Chantier spec | Task(s) | Couvert |
|
||||||
|
|---|---|---|
|
||||||
|
| #1 D&D | Task 4 | ✅ handlers + popover + par-workflow |
|
||||||
|
| #2 Sélecteur statut | Task 3 (+ réutilisé Task 6) | ✅ |
|
||||||
|
| #3 Cartes responsive | Task 8 | ✅ troncature + N |
|
||||||
|
| #4 Couleurs | Task 1 (palette), Task 4 (entêtes), Task 9 (migration + défauts) | ✅ a/b/c |
|
||||||
|
| #5 Bouton lier mail | Task 7 | ✅ |
|
||||||
|
| #6 Création depuis mail | Task 5 (back) + Task 6 (front) | ✅ |
|
||||||
|
| #7 Modale réutilisable | Task 2 (composant) + Task 6/10 (migrations) | ✅ |
|
||||||
|
| #8 MalioSelect catégorie | déjà fait (hors plan) | ✅ |
|
||||||
|
|
||||||
|
**Risques / points de vigilance pour l'exécutant :**
|
||||||
|
- Noms de fonctions/variables existants dans `my-tasks.vue` (rechargement des tâches, toast, `t`) et `TaskModal.vue` (projet effectif) — à raccorder aux noms réels.
|
||||||
|
- `MailMessage` non garanti dans les fixtures → adapter le test backend (Task 5) ou importer un dump mail.
|
||||||
|
- Toujours **hard-reload sans cache** après chaque `build:dist`.
|
||||||
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal file
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Specs — Correctifs UI suite au système de workflow + UX mail/modales
|
||||||
|
|
||||||
|
> Date : 2026-05-20
|
||||||
|
> Contexte : suite à l'introduction des **workflows** (`docs/superpowers/specs/2026-05-19-project-workflows-design.md`),
|
||||||
|
> plusieurs régressions UI et points UX sont apparus. Reviews faites par Lucile Schnödt et Tristan Schnödtin.
|
||||||
|
> Ce document liste les 7 chantiers à traiter, avec problème, fichiers concernés, solution validée et points ouverts.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
| # | Chantier | Type | Décision |
|
||||||
|
|---|----------|------|----------|
|
||||||
|
| 1 | Drag & drop cassé dans « mes tâches » | régression workflow | Drop = changer de statut (gérer ambiguïté multi-statuts) |
|
||||||
|
| 2 | Sélecteur de statut mélange les workflows | régression workflow | Filtrer par le workflow du projet de la tâche |
|
||||||
|
| 3 | Cartes de tâche non responsive (tags) | UI | Refonte responsive + troncature |
|
||||||
|
| 4 | Couleurs de statut du workflow de base | data/UI | Remettre la palette classique |
|
||||||
|
| 5 | Bouton « lier un mail » dans l'onglet mail d'un ticket | UX mail | Supprimer le bouton |
|
||||||
|
| 6 | Création de ticket depuis un mail | UX mail | + sélecteur user, + sélecteur statut (remplace priorité), modale agrandie |
|
||||||
|
| 7 | Footer collant des modales centrées | UX global | Composant modale réutilisable (header / body scrollable / footer sticky) |
|
||||||
|
| 8 | Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) | UI/dette | Migrer vers `MalioSelect` (la lib supporte les valeurs `string`) |
|
||||||
|
|
||||||
|
## Décisions actées (2026-05-21)
|
||||||
|
|
||||||
|
Suite à la reproduction des bugs sur données prod (import local) et discussion :
|
||||||
|
|
||||||
|
- **#1** — Désambiguïsation au drop : **popover** de choix quand la catégorie cible a ≥2 statuts (0 → drop refusé + feedback ; 1 → PATCH direct ; ≥2 → popover ancré au point de drop). Résolution **par tâche** (workflow de son projet).
|
||||||
|
- **#2** — Source des statuts : `project.workflow.statuses` (déjà embarqué dans `GET /projects` et `task.project.workflow`). Le statut courant est ajouté en tête s'il est hors du workflow (pas de perte à l'enregistrement).
|
||||||
|
- **#3** — Cartes : `min-w-0` partout, titre `line-clamp-2`, badges `truncate`, **2-3 tags max + badge « +N »** (tooltip), hauteur de carte fixe.
|
||||||
|
- **#4** — Deux facettes :
|
||||||
|
- **(a) Statuts** (badges cartes + kanban projet) : dérive **data en prod** (fixtures OK). Correction par **migration Doctrine** idempotente remettant les hex classiques sur le workflow Standard.
|
||||||
|
- **(b) Catégories** (entêtes multi-projets de « Mes tâches », aujourd'hui grises) : nouvelle constante front **`STATUS_CATEGORY_COLOR`** (5 hex classiques) → **bandeau teinté** sur les entêtes, **texte auto noir/blanc** selon la luminance.
|
||||||
|
- **(c)** Couleur par défaut **par catégorie** dans `addStatus()` (au lieu de `#222783` systématique) pour éviter une nouvelle dérive.
|
||||||
|
- **#5** — Suppression du bouton « Lier un mail » + `MailPickerModal` + état/handler + clé i18n (`MailPickerModal` n'est utilisé que par TaskModal — vérifié).
|
||||||
|
- **#6** — + sélecteur **user** (défaut pré-rempli) ; priorité **remplacée** par sélecteur **statut** (filtré workflow, rechargé au changement de projet) ; **`priority`/`priorityId` retiré** du payload et de l'endpoint backend ; statut par défaut = **1er statut du workflow par `position`** ; modale élargie via #7.
|
||||||
|
- **#7** — Composant `frontend/components/ui/AppModal.vue` (header fixe / body `flex-1 min-h-0 overflow-y-auto` / footer `shrink-0` sticky, `max-h-[90vh]`, prop `width`). Migration d'abord : TaskModal puis MailCreateTaskModal.
|
||||||
|
- **#8** — ✅ Fait : `<select>` catégorie → `MalioSelect` (la lib accepte `value: string | number | null` ; note CLAUDE.md corrigée).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Drag & drop cassé dans « mes tâches »
|
||||||
|
|
||||||
|
**Problème.** Le drag & drop des cartes entre colonnes ne fonctionne plus depuis l'arrivée des workflows.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/pages/my-tasks.vue` — colonnes kanban construites sur les **catégories canoniques** fixes (`CATEGORIES = ['todo','in_progress','blocked','review','done']`, ~l.118-127), template kanban ~l.396-424.
|
||||||
|
- `frontend/components/task/TaskCard.vue` — `@dragstart` / `@dragend` HTML5 natif (~l.154-162), `dataTransfer` = `task.id`.
|
||||||
|
- Pas de librairie externe (HTML5 natif).
|
||||||
|
|
||||||
|
**Cause racine.** Les colonnes sont des **catégories canoniques** (la vue agrège plusieurs projets/workflows, donc on ne peut pas afficher une colonne par statut d'un workflow précis). Or un workflow peut désormais mapper **plusieurs statuts sur une même catégorie** (ex. deux statuts « in_progress »). Au drop dans une colonne, le statut cible devient ambigu — ce qui a cassé / rendu indéterminé le changement de statut.
|
||||||
|
|
||||||
|
**Solution retenue.** Drop dans une colonne ⇒ change le statut de la tâche vers un statut de cette catégorie **dans le workflow du projet de la tâche**. Gestion de l'ambiguïté :
|
||||||
|
- Récupérer les statuts du workflow du projet de la tâche déposée, filtrés par la catégorie de la colonne cible.
|
||||||
|
- **0 statut** dans cette catégorie pour ce workflow → drop refusé (feedback visuel, pas de changement).
|
||||||
|
- **1 statut** → appliquer directement.
|
||||||
|
- **≥ 2 statuts** → afficher un mini-sélecteur (popover/menu) au point de drop pour choisir le statut exact.
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- ⚠️ **À trancher demain** : confirmer la stratégie de désambiguïsation (popover au drop vs. choisir le statut de plus petite `position` dans la catégorie). Le popover est plus sûr mais plus de travail ; le « plus petite position » est transparent mais peut surprendre.
|
||||||
|
- Ajouter les **handlers de drop** sur les colonnes (`@dragover.prevent`, `@drop`) — actuellement absents dans `my-tasks.vue`.
|
||||||
|
- Comme la vue est multi-projets, la résolution du statut cible doit se faire **par tâche** (selon son projet → son workflow), pas globalement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sélecteur de statut mélange les statuts de tous les workflows
|
||||||
|
|
||||||
|
**Problème.** En ouvrant une tâche (modale), le sélecteur « Statut » liste **tous les statuts globaux**, donc ceux du workflow de base **et** ceux des autres workflows.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/components/task/TaskModal.vue` — `<MalioSelect v-model="form.statusId" :options="statusOptions" />` (~l.103-109) ; `statusOptions = props.statuses.map(...)` (~l.674-676).
|
||||||
|
- `frontend/pages/my-tasks.vue` — `:statuses="statuses"` (~l.489), chargés via `statusService.getAll()` (~l.132).
|
||||||
|
- `frontend/services/task-statuses.ts` — `getAll()` → `GET /task_statuses` (tous les statuts).
|
||||||
|
- DTO : `frontend/services/dto/task-status.ts` — le champ `workflow` existe déjà (`{ '@id', id } | string`).
|
||||||
|
|
||||||
|
**Solution retenue.** Le sélecteur ne doit proposer que les statuts du **workflow du projet de la tâche éditée**.
|
||||||
|
- Filtrer `statusOptions` sur `status.workflow` correspondant au workflow du projet de la tâche.
|
||||||
|
- Source du filtre : soit filtrer côté front la liste déjà chargée par `workflow.id`, soit charger les statuts du workflow via le service workflow (`useWorkflowService()` existe mais n'est pas utilisé ici).
|
||||||
|
- S'assurer que le statut courant de la tâche reste affiché même si édge case (ex. statut hérité d'un ancien workflow).
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- Vérifier que la tâche/le projet expose bien l'`@id`/`id` du workflow côté payload pour faire le filtre sans appel supplémentaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cartes de tâche non responsive (tags mal placés / trop gros)
|
||||||
|
|
||||||
|
**Problème.** Depuis l'ajout d'éléments dans la carte (statut, priorité, effort, tags, deadline, avatars…), les tags débordent ou se chevauchent quand les textes sont longs ; la carte n'est plus responsive.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/components/task/TaskCard.vue` — badges statut (~l.43-49) et tags (~l.58-64) : `rounded-full px-2 py-0.5 text-xs font-semibold text-white`, **sans `truncate`, sans `max-w`, sans `line-clamp`** ; conteneur `flex` sans contrainte de largeur (~l.42-106).
|
||||||
|
|
||||||
|
**Solution retenue.** Refonte du layout de la carte pour rester contenue quelle que soit la longueur des textes :
|
||||||
|
- Conteneur des badges en `flex flex-wrap gap-1` (retour à la ligne propre).
|
||||||
|
- Tags : `max-w-[...] truncate` (ou `line-clamp-1`) + `title`/tooltip pour le texte complet au survol.
|
||||||
|
- Hiérarchiser l'info : titre prioritaire (`line-clamp-2`), badges secondaires qui passent à la ligne ou se condensent.
|
||||||
|
- Option : limiter le nombre de tags affichés (ex. 2-3 + « +N »).
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- ⚠️ Choix d'UX à valider : `flex-wrap` (tous les tags visibles, carte plus haute) vs. troncature « +N » (hauteur fixe). Décision visuelle à prendre demain (éventuellement via mockup).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Couleurs de statut du workflow de base à remettre
|
||||||
|
|
||||||
|
**Problème.** Les couleurs « classiques » des statuts du workflow de base ont changé ; il faut remettre les couleurs d'origine.
|
||||||
|
|
||||||
|
**Investigation faite.** Le commit `4775cbf` (palette élargie 9→18 teintes + couleur perso) ne touche **que** `ColorPicker.vue` et `ProjectDrawer.vue` — il n'a pas modifié les couleurs des statuts. Les couleurs d'origine du **workflow Standard** sont dans les fixtures :
|
||||||
|
|
||||||
|
| Catégorie | Statut | Hex classique |
|
||||||
|
|---|---|---|
|
||||||
|
| todo | À faire | `#222783` |
|
||||||
|
| in_progress | En cours | `#4A90D9` |
|
||||||
|
| blocked | Bloqué | `#C62828` |
|
||||||
|
| review | En attente de validation | `#FF8F00` |
|
||||||
|
| done | Terminé | `#26A69A` |
|
||||||
|
|
||||||
|
Source : `src/DataFixtures/AppFixtures.php:101-105` (statuts rattachés au `$standardWorkflow`, ~l.93-116).
|
||||||
|
Migration de rattachement : `migrations/Version20260519175114.php` (attache les statuts existants au workflow Standard).
|
||||||
|
|
||||||
|
**Solution retenue.** Remettre ces 5 couleurs sur les statuts du **workflow Standard/de base**.
|
||||||
|
- Vérifier en prod (et en base de dev si dérive) que les statuts du workflow Standard portent bien ces hex ; corriger ceux qui ont dérivé (via l'UI d'admin des statuts, ou un script/migration de correction des couleurs).
|
||||||
|
- Ne **pas** toucher aux couleurs des statuts des autres workflows.
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- Confirmer où la dérive a eu lieu (prod vs. nouveaux workflows créés via l'UI avec d'autres couleurs). Si c'est un workflow Standard en prod avec des couleurs erronées → correction data ; si c'est par défaut à la création d'un workflow → ajuster les couleurs proposées par défaut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Supprimer le bouton « lier un mail » dans l'onglet mail d'un ticket
|
||||||
|
|
||||||
|
**Problème.** En ouvrant un ticket, l'onglet mail propose un bouton « lier un mail » qui n'a pas d'utilité de ce côté.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/components/task/TaskModal.vue` — bouton « lier un mail » (~l.486-494, `mail.taskTab.linkButton`, ouvre `showMailPickerModal`) ; `<MailPickerModal>` (~l.498-503), visible si `isEditing && isMailUser`, onglet `mails`.
|
||||||
|
- Composant lié : `frontend/components/mail/MailPickerModal.vue`.
|
||||||
|
|
||||||
|
**Solution retenue.** Supprimer le bouton « lier un mail » de l'onglet mail du ticket.
|
||||||
|
- Retirer le bouton et, si plus aucun usage, le `MailPickerModal` associé + l'état `showMailPickerModal` + `handleMailLinked`.
|
||||||
|
- Nettoyer la clé i18n `mail.taskTab.linkButton` si elle n'est plus utilisée ailleurs.
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- Vérifier que `MailPickerModal` n'est pas réutilisé ailleurs avant de le supprimer (sinon ne retirer que le bouton).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Création d'un ticket depuis un mail
|
||||||
|
|
||||||
|
**Problème.** Le formulaire de création depuis un mail est incomplet et la modale dépasse de l'écran.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/components/mail/MailCreateTaskModal.vue` — champs actuels (~l.119-224) : info source (lecture seule), Projet (requis), Groupe (optionnel), **Priorité** (optionnelle). Footer non sticky (~l.210-224).
|
||||||
|
- `frontend/services/mail.ts` — `createTaskFromMail()` (~l.184-192) → `POST /mail/messages/{id}/create-task` avec `{ projectId, taskGroupId, priority }`.
|
||||||
|
|
||||||
|
**Solution retenue.**
|
||||||
|
1. **Sélecteur de user (assigné).** Ajouter un sélecteur d'utilisateur. Un user par défaut est déjà appliqué ; le sélecteur permet d'en choisir un autre. (Charger la liste via le service users.)
|
||||||
|
2. **Statut à la place de la priorité.** **Retirer** le sélecteur de priorité, le **remplacer** par un sélecteur de statut. Le statut proposé doit respecter le workflow du projet sélectionné (cf. chantier #2 — réutiliser la même logique de filtrage par workflow). Au changement de projet, recharger les statuts du workflow correspondant.
|
||||||
|
3. **Agrandir la modale.** Élargir la largeur (et gérer la hauteur via body scrollable + footer sticky, cf. chantier #7) pour qu'elle ne dépasse plus.
|
||||||
|
4. **Backend.** Adapter le payload / l'endpoint `create-task` : accepter `assigneeId` (ou IRI user) et `statusId` (ou IRI statut) ; retirer/garder `priority` selon décision (ici : remplacé par statut côté UI — décider si on garde la priorité par défaut côté backend ou non).
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- Confirmer le statut **par défaut** présélectionné (statut initial du workflow du projet).
|
||||||
|
- Décider si la priorité disparaît totalement du payload ou reste à une valeur par défaut côté backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Footer collant pour les modales centrées
|
||||||
|
|
||||||
|
**Problème.** Les modales qui s'ouvrent au milieu de l'écran ont leurs boutons d'action en bas, qui défilent avec le contenu et finissent hors écran. On veut un footer **toujours visible**.
|
||||||
|
|
||||||
|
**Constat.** Il n'existe **aucun composant modale réutilisable** (`MalioModal`/`AppModal`). Chaque modale réimplémente `Teleport` + `Transition` + header/body/footer. Footers actuels en `border-t` mais **non sticky** :
|
||||||
|
- `frontend/components/task/TaskModal.vue` (footer ~l.507-549)
|
||||||
|
- `frontend/components/mail/MailCreateTaskModal.vue` (~l.210-224)
|
||||||
|
- `frontend/components/mail/MailLinkTaskModal.vue` (~l.226-239)
|
||||||
|
- `frontend/components/mail/MailPickerModal.vue` (~l.187-201)
|
||||||
|
- `frontend/components/ui/ConfirmDeleteTaskModal.vue` (~l.11-24)
|
||||||
|
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` (~l.63-76)
|
||||||
|
|
||||||
|
**Solution retenue (décision : composant réutilisable).** Créer un composant modale réutilisable dans `frontend/components/ui/` (ex. `AppModal.vue` / `MalioModal.vue` local) :
|
||||||
|
- Structure : `Teleport to="body"` + `Transition`, overlay `fixed inset-0`, conteneur avec **hauteur max** (`max-h-[90vh]`), en flex-col :
|
||||||
|
- **header** (titre + fermeture) fixe en haut,
|
||||||
|
- **body** `flex-1 overflow-y-auto`,
|
||||||
|
- **footer** sticky en bas (`shrink-0 border-t`), via slot `#footer`.
|
||||||
|
- Props : largeur (`sm`/`md`/`lg`/`xl`), titre, `v-model` ouverture ; slots `default` (body) et `footer`.
|
||||||
|
- **Migration progressive** des modales existantes vers ce composant (commencer par celles citées par les reviews : TaskModal, MailCreateTaskModal). Ne pas tout migrer d'un coup.
|
||||||
|
|
||||||
|
**Points ouverts.**
|
||||||
|
- Nom et emplacement définitifs du composant.
|
||||||
|
- Ordre de migration (au minimum : MailCreateTaskModal #6 et TaskModal #2/#5, qui sont déjà touchées par les autres chantiers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) → MalioSelect
|
||||||
|
|
||||||
|
**Problème.** Dans l'éditeur de statut d'un workflow, le champ « Catégorie » est un `<select>` HTML natif, visuellement incohérent avec le reste des formulaires (qui utilisent `MalioSelect`).
|
||||||
|
|
||||||
|
**Investigation faite (2026-05-21).** La note de `Lesstime/CLAUDE.md` affirmait que `MalioSelect` n'accepte que des `value: number | null` et qu'il fallait un `<select>` natif pour les enums string. **C'est faux.** Vérifié dans la source `@malio/layer-ui` (v1.4.8 installée **et** repo dev `malio-layer-ui`) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/components/malio/select/Select.vue
|
||||||
|
type Option = { label: string; value: string | number | null } // string supporté
|
||||||
|
const props = withDefaults(defineProps<{ modelValue: string | number | null; ... }>(), ...)
|
||||||
|
emit('update:modelValue', v: string | number | null)
|
||||||
|
// normalizedOptions n'ajoute l'option vide {value:null} QUE si empty-option-label est passé
|
||||||
|
```
|
||||||
|
|
||||||
|
La comparaison interne utilise `===`, donc les valeurs `string` (ex. l'enum `StatusCategory` : `todo | in_progress | blocked | review | done`) fonctionnent nativement.
|
||||||
|
|
||||||
|
**Fichiers.**
|
||||||
|
- `frontend/components/admin/WorkflowDrawer.vue` — `<select v-model="s.category">` (~l.49-57), `categoryOptions: { value: StatusCategory, label }[]` (~l.145-151).
|
||||||
|
- Note corrigée : `Lesstime/CLAUDE.md` (section « Frontend »).
|
||||||
|
|
||||||
|
**Solution retenue (faite).** Remplacer le `<select>` natif par :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioSelect
|
||||||
|
v-model="s.category"
|
||||||
|
:options="categoryOptions"
|
||||||
|
label="Catégorie"
|
||||||
|
min-width="w-44"
|
||||||
|
group-class="shrink-0"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Pas d'`empty-option-label` (catégorie requise → pas d'option « Aucune » `null`).
|
||||||
|
- `min-width="w-44"` pour rester compact dans la ligne `flex` (sinon défaut `w-96`).
|
||||||
|
|
||||||
|
**Points ouverts / suite possible.**
|
||||||
|
- D'autres `<select>` natifs subsistent pour des enums string et pourraient être migrés de la même façon (candidats : `AdminClientTicketTab.vue`, `AdminMailTab.vue`, `ProjectClientTickets.vue`, `ProjectWorkflowSwitchModal.vue`, `TaskModal.vue`). À traiter au cas par cas, hors scope immédiat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Découpage suggéré pour l'implémentation
|
||||||
|
|
||||||
|
Regrouper par zone pour limiter les conflits :
|
||||||
|
|
||||||
|
- **Lot A — Workflow / statuts** : #2 (filtrage statut) → réutilisé par #1 (D&D) et #6 (statut à la création mail).
|
||||||
|
- **Lot B — Cartes** : #3 (responsive) + #4 (couleurs classiques).
|
||||||
|
- **Lot C — Mail** : #5 (suppr. bouton) + #6 (form création) — dépend de #2 et #7.
|
||||||
|
- **Lot D — Modale réutilisable** : #7 (composant) puis migration de TaskModal et MailCreateTaskModal.
|
||||||
|
|
||||||
|
Ordre conseillé : **#7 (composant modale)** et **#2 (filtrage statut)** d'abord (briques réutilisées), puis #1, #6, #5, puis #3/#4.
|
||||||
@@ -46,15 +46,13 @@
|
|||||||
label="Libellé"
|
label="Libellé"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<select
|
<MalioSelect
|
||||||
v-model="s.category"
|
v-model="s.category"
|
||||||
class="h-10 rounded border border-neutral-300 px-2 text-sm"
|
:options="categoryOptions"
|
||||||
aria-label="Catégorie"
|
label="Catégorie"
|
||||||
>
|
min-width="w-44"
|
||||||
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
|
group-class="shrink-0"
|
||||||
{{ c.label }}
|
/>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-10 px-2 text-red-600 hover:text-red-800"
|
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||||
@@ -97,6 +95,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||||
|
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||||
import { useWorkflowService } from '~/services/workflows'
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
@@ -171,10 +170,20 @@ watch(() => props.modelValue, (open) => {
|
|||||||
touched.name = false
|
touched.name = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||||
|
if (!prev) return
|
||||||
|
cats.forEach((cat, i) => {
|
||||||
|
const s = form.statuses[i]
|
||||||
|
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||||
|
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function addStatus() {
|
function addStatus() {
|
||||||
form.statuses.push({
|
form.statuses.push({
|
||||||
label: '',
|
label: '',
|
||||||
color: '#222783',
|
color: STATUS_CATEGORY_COLOR.todo,
|
||||||
position: form.statuses.length,
|
position: form.statuses.length,
|
||||||
isFinal: false,
|
isFinal: false,
|
||||||
category: 'todo',
|
category: 'todo',
|
||||||
|
|||||||
@@ -3,74 +3,63 @@ import type { MailMessageDetailDto } from '~/services/dto/mail'
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useMailService } from '~/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** v-model: true = modal ouvert */
|
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
/** ID BDD du message source */
|
|
||||||
messageId: number
|
messageId: number
|
||||||
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
|
|
||||||
messageDetail: MailMessageDetailDto | null
|
messageDetail: MailMessageDetailDto | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
/** Émis après création réussie — payload = tâche créée */
|
|
||||||
created: [task: Task]
|
created: [task: Task]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const auth = useAuthStore()
|
||||||
const mailService = useMailService()
|
const mailService = useMailService()
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const taskGroupService = useTaskGroupService()
|
const taskGroupService = useTaskGroupService()
|
||||||
const priorityService = useTaskPriorityService()
|
const userService = useUserService()
|
||||||
|
|
||||||
// ─── État formulaire ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const projectId = ref<number | null>(null)
|
const projectId = ref<number | null>(null)
|
||||||
const taskGroupId = ref<number | null>(null)
|
const taskGroupId = ref<number | null>(null)
|
||||||
const priorityId = ref<number | null>(null)
|
const assigneeId = ref<number | null>(null)
|
||||||
|
const statusId = ref<number | null>(null)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const touchedProject = ref(false)
|
const touchedProject = ref(false)
|
||||||
|
|
||||||
// ─── Données de référence ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const groups = ref<TaskGroup[]>([])
|
const groups = ref<TaskGroup[]>([])
|
||||||
const priorities = ref<TaskPriority[]>([])
|
const users = ref<UserData[]>([])
|
||||||
const loadingGroups = ref(false)
|
const loadingGroups = ref(false)
|
||||||
|
|
||||||
const projectOptions = computed(() =>
|
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||||
)
|
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||||
|
|
||||||
const groupOptions = computed(() =>
|
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
const statusOptions = computed(() =>
|
||||||
|
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const priorityOptions = computed(() =>
|
|
||||||
priorities.value.map(p => ({ label: p.label, value: p.id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Chargement initial ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [projs, prios] = await Promise.all([
|
const [projs, us] = await Promise.all([
|
||||||
projectService.getAll({ archived: false }),
|
projectService.getAll({ archived: false }),
|
||||||
priorityService.getAll(),
|
userService.getAll(),
|
||||||
])
|
])
|
||||||
projects.value = projs
|
projects.value = projs
|
||||||
priorities.value = prios
|
users.value = us
|
||||||
})
|
})
|
||||||
|
|
||||||
// Recharger les groupes quand le projet change
|
|
||||||
watch(projectId, async (pid) => {
|
watch(projectId, async (pid) => {
|
||||||
taskGroupId.value = null
|
taskGroupId.value = null
|
||||||
|
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||||
groups.value = []
|
groups.value = []
|
||||||
if (!pid) return
|
if (!pid) return
|
||||||
loadingGroups.value = true
|
loadingGroups.value = true
|
||||||
@@ -81,18 +70,16 @@ watch(projectId, async (pid) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset formulaire à l'ouverture
|
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
projectId.value = null
|
projectId.value = null
|
||||||
taskGroupId.value = null
|
taskGroupId.value = null
|
||||||
priorityId.value = null
|
statusId.value = null
|
||||||
|
assigneeId.value = auth.user?.id ?? null
|
||||||
touchedProject.value = false
|
touchedProject.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
@@ -100,13 +87,13 @@ function close(): void {
|
|||||||
async function handleSubmit(): Promise<void> {
|
async function handleSubmit(): Promise<void> {
|
||||||
touchedProject.value = true
|
touchedProject.value = true
|
||||||
if (!projectId.value) return
|
if (!projectId.value) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
taskGroupId: taskGroupId.value ?? undefined,
|
taskGroupId: taskGroupId.value ?? undefined,
|
||||||
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
|
assigneeId: assigneeId.value ?? undefined,
|
||||||
|
statusId: statusId.value ?? undefined,
|
||||||
})
|
})
|
||||||
emit('created', task)
|
emit('created', task)
|
||||||
close()
|
close()
|
||||||
@@ -117,135 +104,41 @@ async function handleSubmit(): Promise<void> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport v-if="modelValue" to="body">
|
<AppModal
|
||||||
<Transition name="mail-modal" appear>
|
:model-value="modelValue"
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
width="lg"
|
||||||
<!-- Backdrop -->
|
:title="t('mail.createTaskModal.title')"
|
||||||
<div
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
>
|
||||||
@click="close"
|
<div class="space-y-5">
|
||||||
/>
|
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||||
|
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||||
<!-- Modal -->
|
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||||
<div
|
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||||
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="priorityOptions"
|
|
||||||
: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>
|
</div>
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div v-if="projectId">
|
||||||
|
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<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" />
|
||||||
|
</template>
|
||||||
|
</AppModal>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
<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 le dossier actuellement sélectionné dans le store si disponible,
|
|
||||||
// sinon fallback sur 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>
|
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.code"
|
v-model="codeProxy"
|
||||||
label="Code"
|
label="Code"
|
||||||
input-class="w-full uppercase"
|
input-class="w-full"
|
||||||
|
:max-length="10"
|
||||||
:disabled="isEditing"
|
:disabled="isEditing"
|
||||||
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
|
: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"
|
@blur="touched.code = true"
|
||||||
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
@@ -186,6 +186,17 @@ const touched = reactive({
|
|||||||
name: 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(() =>
|
const clientOptions = computed(() =>
|
||||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||||
)
|
)
|
||||||
@@ -222,7 +233,7 @@ async function handleSubmit() {
|
|||||||
touched.name = true
|
touched.name = true
|
||||||
touched.code = true
|
touched.code = true
|
||||||
if (!form.name.trim()) return
|
if (!form.name.trim()) return
|
||||||
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
|
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -254,7 +265,7 @@ async function handleSubmit() {
|
|||||||
if (isEditing.value && props.project) {
|
if (isEditing.value && props.project) {
|
||||||
await update(props.project.id, payload)
|
await update(props.project.id, payload)
|
||||||
} else {
|
} else {
|
||||||
payload.code = form.code.trim()
|
payload.code = form.code
|
||||||
await create(payload)
|
await create(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
frontend/components/task/StatusPickerPopover.vue
Normal file
35
frontend/components/task/StatusPickerPopover.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskStatus } from '~/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>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex items-center gap-1.5">
|
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-if="showStatusBadge && task.status"
|
v-if="showStatusBadge && task.status"
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
<form @submit.prevent="handleSubmit" class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||||
<nav class="flex gap-6">
|
<nav class="flex gap-6">
|
||||||
@@ -481,73 +481,54 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
</form>
|
||||||
<div
|
|
||||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
<!-- Footer -->
|
||||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
<div
|
||||||
>
|
class="shrink-0 flex items-center border-t border-neutral-100 bg-white px-4 py-4 sm:px-8 sm:py-5"
|
||||||
|
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||||
|
>
|
||||||
|
<MalioButton
|
||||||
|
v-if="isEditing"
|
||||||
|
variant="danger"
|
||||||
|
label="Supprimer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="confirmDeleteOpen = true"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-3">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="canArchive"
|
||||||
variant="danger"
|
variant="tertiary"
|
||||||
label="Supprimer"
|
:label="$t('archive.archiveButton')"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="confirmDeleteOpen = true"
|
@click="handleArchive"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canUnarchive"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleUnarchive"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
label="Enregistrer"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-3">
|
|
||||||
<MalioButton
|
|
||||||
v-if="canArchive"
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('archive.archiveButton')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleArchive"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
v-if="canUnarchive"
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('archive.unarchiveButton')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleUnarchive"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
label="Annuler"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="close"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
label="Enregistrer"
|
|
||||||
button-class="w-auto px-6"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<ConfirmDeleteTaskModal
|
<ConfirmDeleteTaskModal
|
||||||
v-model="confirmDeleteOpen"
|
v-model="confirmDeleteOpen"
|
||||||
@@ -624,7 +605,6 @@ const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
|||||||
const mailService = useMailService()
|
const mailService = useMailService()
|
||||||
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
||||||
const mailsLoading = ref(false)
|
const mailsLoading = ref(false)
|
||||||
const showMailPickerModal = ref(false)
|
|
||||||
|
|
||||||
const giteaUrl = ref('')
|
const giteaUrl = ref('')
|
||||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||||
@@ -671,10 +651,27 @@ const touched = reactive({
|
|||||||
project: false,
|
project: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
|
||||||
|
const projectOptions = computed(() =>
|
||||||
|
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resolvedProjectId = computed(() =>
|
||||||
|
showProjectSelect.value ? form.projectId : props.projectId
|
||||||
|
)
|
||||||
|
|
||||||
|
const statusOptions = computed(() => {
|
||||||
|
const project = props.projects?.find(p => p.id === resolvedProjectId.value)
|
||||||
|
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||||
|
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
const current = props.task?.status
|
||||||
|
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||||
|
opts.unshift({ label: current.label, value: current.id })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
})
|
||||||
|
|
||||||
const effortOptions = computed(() =>
|
const effortOptions = computed(() =>
|
||||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||||
)
|
)
|
||||||
@@ -707,16 +704,6 @@ const groupOptions = computed(() => {
|
|||||||
return filtered.map(g => ({ label: g.title, value: g.id }))
|
return filtered.map(g => ({ label: g.title, value: g.id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
|
||||||
|
|
||||||
const projectOptions = computed(() =>
|
|
||||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolvedProjectId = computed(() =>
|
|
||||||
showProjectSelect.value ? form.projectId : props.projectId
|
|
||||||
)
|
|
||||||
|
|
||||||
const canArchive = computed(() => {
|
const canArchive = computed(() => {
|
||||||
if (!isEditing.value || !props.task) return false
|
if (!isEditing.value || !props.task) return false
|
||||||
if (props.task.archived) return false
|
if (props.task.archived) return false
|
||||||
@@ -933,11 +920,6 @@ watch(activeTab, async (tab) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleMailLinked(): Promise<void> {
|
|
||||||
showMailPickerModal.value = false
|
|
||||||
await loadLinkedMails()
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMailDate(iso: string | null): string {
|
function formatMailDate(iso: string | null): string {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
return new Date(iso).toLocaleDateString('fr', {
|
return new Date(iso).toLocaleDateString('fr', {
|
||||||
|
|||||||
87
frontend/components/ui/AppModal.vue
Normal file
87
frontend/components/ui/AppModal.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
/** Largeur max du panneau */
|
||||||
|
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
}>(), {
|
||||||
|
title: '',
|
||||||
|
width: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="app-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 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||||
|
:class="WIDTH_CLASS[width]"
|
||||||
|
>
|
||||||
|
<!-- Header (fixe) -->
|
||||||
|
<div class="flex shrink-0 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">
|
||||||
|
<slot name="title">{{ title }}</slot>
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body (scrollable) -->
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer (sticky) -->
|
||||||
|
<div
|
||||||
|
v-if="$slots.footer"
|
||||||
|
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||||
|
>
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-modal-enter-active,
|
||||||
|
.app-modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.app-modal-enter-active > div:last-child,
|
||||||
|
.app-modal-leave-active > div:last-child {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.app-modal-enter-from,
|
||||||
|
.app-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.app-modal-enter-from > div:last-child {
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
v-for="color in colors"
|
v-for="color in presets"
|
||||||
:key="color"
|
:key="color"
|
||||||
type="button"
|
type="button"
|
||||||
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
||||||
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
:class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||||
:style="{ backgroundColor: color }"
|
:style="{ backgroundColor: color }"
|
||||||
@click="emit('update:modelValue', color)"
|
:aria-label="`Choisir la couleur ${color}`"
|
||||||
|
@click="select(color)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Couleur personnalisée : input natif déguisé en pastille -->
|
||||||
|
<label
|
||||||
|
class="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border-2 transition-transform hover:scale-110"
|
||||||
|
:class="isCustom ? 'border-neutral-900 scale-110' : 'border-dashed border-neutral-400'"
|
||||||
|
:style="isCustom ? { backgroundColor: modelValue } : {}"
|
||||||
|
title="Couleur personnalisée"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
:value="modelValue"
|
||||||
|
aria-label="Choisir une couleur personnalisée"
|
||||||
|
@input="select(($event.target as HTMLInputElement).value)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="!isCustom"
|
||||||
|
name="mdi:plus"
|
||||||
|
class="text-neutral-500"
|
||||||
|
size="20"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -24,8 +47,26 @@ const emit = defineEmits<{
|
|||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const colors = [
|
// Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
|
||||||
'#222783', '#26A69A', '#E91E63', '#4A90D9',
|
// pour que les projets/tags existants restent associés à une pastille.
|
||||||
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
|
const presets = [
|
||||||
|
'#222783', '#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
|
||||||
|
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043', '#EF4444',
|
||||||
|
'#F97316', '#F59E0B', '#22C55E', '#10B981', '#06B6D4',
|
||||||
|
'#3B82F6', '#8B5CF6', '#64748B',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const norm = (value: string): string => (value ?? '').toUpperCase()
|
||||||
|
|
||||||
|
function isSelected(color: string): boolean {
|
||||||
|
return norm(props.modelValue) === norm(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustom = computed(
|
||||||
|
() => !!props.modelValue && !presets.some((c) => norm(c) === norm(props.modelValue)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(value: string): void {
|
||||||
|
emit('update:modelValue', norm(value))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -236,7 +236,8 @@
|
|||||||
"sortBy": "Trier par",
|
"sortBy": "Trier par",
|
||||||
"sortDefault": "Par défaut",
|
"sortDefault": "Par défaut",
|
||||||
"sortDeadline": "Échéance",
|
"sortDeadline": "Échéance",
|
||||||
"sortScheduledStart": "Date planifiée"
|
"sortScheduledStart": "Date planifiée",
|
||||||
|
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -570,8 +571,9 @@
|
|||||||
"projectPlaceholder": "Sélectionner un projet",
|
"projectPlaceholder": "Sélectionner un projet",
|
||||||
"groupLabel": "Groupe (optionnel)",
|
"groupLabel": "Groupe (optionnel)",
|
||||||
"groupPlaceholder": "Aucun groupe",
|
"groupPlaceholder": "Aucun groupe",
|
||||||
"priorityLabel": "Priorité (optionnelle)",
|
"statusLabel": "Statut",
|
||||||
"priorityPlaceholder": "Aucune priorité",
|
"assigneeLabel": "Assigné à",
|
||||||
|
"assigneePlaceholder": "Aucun",
|
||||||
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
||||||
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
||||||
},
|
},
|
||||||
@@ -584,17 +586,9 @@
|
|||||||
"empty": "Aucune tâche correspondante.",
|
"empty": "Aucune tâche correspondante.",
|
||||||
"loading": "Recherche en cours…"
|
"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": {
|
"taskTab": {
|
||||||
"title": "Mails",
|
"title": "Mails",
|
||||||
"empty": "Aucun mail lié à cette tâche.",
|
"empty": "Aucun mail lié à cette tâche.",
|
||||||
"linkButton": "Lier un mail",
|
|
||||||
"openInMailer": "Ouvrir dans la messagerie",
|
"openInMailer": "Ouvrir dans la messagerie",
|
||||||
"unlinkConfirm": "Délier ce mail ?"
|
"unlinkConfirm": "Délier ce mail ?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { StatusCategory } from '~/services/dto/workflow'
|
import type { StatusCategory } from '~/services/dto/workflow'
|
||||||
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
@@ -71,6 +71,48 @@ const selectedTask = ref<Task | null>(null)
|
|||||||
// Timer
|
// Timer
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Drag & drop
|
||||||
|
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||||
|
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||||
|
|
||||||
|
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||||
|
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
|
||||||
|
const project = projects.value.find(p => p.id === task.project?.id)
|
||||||
|
const wf = project?.workflow
|
||||||
|
if (!wf || typeof wf === 'string') return []
|
||||||
|
return wf.statuses.filter(s => s.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||||
|
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||||
|
await loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||||
|
dragOverCategory.value = null
|
||||||
|
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
const task = tasks.value.find(t => t.id === taskId)
|
||||||
|
if (!task) return
|
||||||
|
const candidates = statusesForTaskCategory(task, category)
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
toast.error({ message: t('myTasks.dropRefused') })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (candidates.length === 1) {
|
||||||
|
void applyStatus(task, candidates[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerChoice(status: TaskStatus): void {
|
||||||
|
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||||
|
pendingPicker.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function isTimerOnTask(task: Task): boolean {
|
function isTimerOnTask(task: Task): boolean {
|
||||||
const entry = timerStore.activeEntry
|
const entry = timerStore.activeEntry
|
||||||
if (!entry?.task) return false
|
if (!entry?.task) return false
|
||||||
@@ -397,9 +439,16 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-for="cat in CATEGORIES"
|
v-for="cat in CATEGORIES"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||||
|
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||||
|
@dragover.prevent="dragOverCategory = cat"
|
||||||
|
@dragleave="dragOverCategory = null"
|
||||||
|
@drop="onDrop(cat, $event)"
|
||||||
>
|
>
|
||||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
<div
|
||||||
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||||
|
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||||
|
>
|
||||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
@@ -481,6 +530,16 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
|
||||||
|
<StatusPickerPopover
|
||||||
|
v-if="pendingPicker"
|
||||||
|
:statuses="pendingPicker.statuses"
|
||||||
|
:x="pendingPicker.x"
|
||||||
|
:y="pendingPicker.y"
|
||||||
|
@pick="onPickerChoice"
|
||||||
|
@cancel="pendingPicker = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- TaskModal -->
|
<!-- TaskModal -->
|
||||||
<TaskModal
|
<TaskModal
|
||||||
v-model="taskModalOpen"
|
v-model="taskModalOpen"
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export type MailMessageFlagInput = {
|
|||||||
export type MailCreateTaskInput = {
|
export type MailCreateTaskInput = {
|
||||||
projectId: number
|
projectId: number
|
||||||
taskGroupId?: number | null
|
taskGroupId?: number | null
|
||||||
priority?: string | null
|
assigneeId?: number
|
||||||
|
statusId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input : lier une tâche existante à un mail
|
// Input : lier une tâche existante à un mail
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
|||||||
done: 'Terminé',
|
done: 'Terminé',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||||
|
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||||
|
todo: '#222783',
|
||||||
|
in_progress: '#4A90D9',
|
||||||
|
blocked: '#C62828',
|
||||||
|
review: '#FF8F00',
|
||||||
|
done: '#26A69A',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||||
|
export function contrastText(hex: string): string {
|
||||||
|
const c = hex.replace('#', '')
|
||||||
|
const r = parseInt(c.slice(0, 2), 16)
|
||||||
|
const g = parseInt(c.slice(2, 4), 16)
|
||||||
|
const b = parseInt(c.slice(4, 6), 16)
|
||||||
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||||
|
}
|
||||||
|
|
||||||
export type Workflow = {
|
export type Workflow = {
|
||||||
id: number
|
id: number
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
|
|||||||
37
migrations/Version20260521094948.php
Normal file
37
migrations/Version20260521094948.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260521094948 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Remet les couleurs classiques sur les statuts du workflow Standard (derive data prod).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'todo' => '#222783',
|
||||||
|
'in_progress' => '#4A90D9',
|
||||||
|
'blocked' => '#C62828',
|
||||||
|
'review' => '#FF8F00',
|
||||||
|
'done' => '#26A69A',
|
||||||
|
];
|
||||||
|
foreach ($map as $category => $hex) {
|
||||||
|
$this->addSql(
|
||||||
|
"UPDATE task_status SET color = '".$hex."' WHERE category = '".$category."' AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->throwIrreversibleMigration('Correction de couleurs non reversible.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ use App\Entity\Project;
|
|||||||
use App\Entity\Task;
|
use App\Entity\Task;
|
||||||
use App\Entity\TaskGroup;
|
use App\Entity\TaskGroup;
|
||||||
use App\Entity\TaskMailLink;
|
use App\Entity\TaskMailLink;
|
||||||
use App\Entity\TaskPriority;
|
use App\Entity\TaskStatus;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Repository\MailMessageRepository;
|
use App\Repository\MailMessageRepository;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
use App\Security\MailAccessChecker;
|
use App\Security\MailAccessChecker;
|
||||||
@@ -74,13 +75,25 @@ class MailCreateTaskController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($body['priorityId']) && null !== $body['priorityId']) {
|
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
|
||||||
$priority = $this->em->getRepository(TaskPriority::class)->find($body['priorityId']);
|
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
|
||||||
if (null !== $priority) {
|
if (null !== $assignee) {
|
||||||
$task->setPriority($priority);
|
$task->setAssignee($assignee);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
|
||||||
|
$status = null;
|
||||||
|
if (isset($body['statusId']) && null !== $body['statusId']) {
|
||||||
|
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
|
||||||
|
}
|
||||||
|
if (null === $status) {
|
||||||
|
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
|
||||||
|
}
|
||||||
|
if (null !== $status) {
|
||||||
|
$task->setStatus($status);
|
||||||
|
}
|
||||||
|
|
||||||
$this->em->persist($task);
|
$this->em->persist($task);
|
||||||
|
|
||||||
$link = new TaskMailLink();
|
$link = new TaskMailLink();
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Functional\Controller\Mail;
|
namespace App\Tests\Functional\Controller\Mail;
|
||||||
|
|
||||||
|
use App\Entity\MailFolder;
|
||||||
|
use App\Entity\MailMessage;
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Entity\Task;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,4 +74,67 @@ class MailTaskIntegrationControllerTest extends WebTestCase
|
|||||||
|
|
||||||
self::assertResponseStatusCodeSame(401);
|
self::assertResponseStatusCodeSame(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$container = static::getContainer();
|
||||||
|
$em = $container->get('doctrine.orm.entity_manager');
|
||||||
|
|
||||||
|
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||||
|
$client->loginUser($admin);
|
||||||
|
|
||||||
|
$project = $em->getRepository(Project::class)->findOneBy([]);
|
||||||
|
self::assertNotNull($project);
|
||||||
|
$status = $project->getWorkflow()->getStatuses()->first();
|
||||||
|
self::assertNotFalse($status);
|
||||||
|
|
||||||
|
// Create a mail folder + message in the test DB (none in fixtures)
|
||||||
|
$folder = new MailFolder();
|
||||||
|
$folder->setDisplayName('Boîte de réception');
|
||||||
|
$folder->setUnreadCount(0);
|
||||||
|
$folder->setTotalCount(0);
|
||||||
|
$em->persist($folder);
|
||||||
|
|
||||||
|
$rand = random_int(100000, 999999);
|
||||||
|
$folder->setPath('INBOX.'.$rand);
|
||||||
|
|
||||||
|
$message = new MailMessage();
|
||||||
|
$message->setMessageId('test-'.$rand.'@example.com');
|
||||||
|
$message->setFolder($folder);
|
||||||
|
$message->setUid($rand);
|
||||||
|
$message->setFromAddress('sender@example.com');
|
||||||
|
$message->setToAddresses([]);
|
||||||
|
$message->setSentAt(new DateTimeImmutable());
|
||||||
|
$message->setIsRead(false);
|
||||||
|
$message->setIsFlagged(false);
|
||||||
|
$message->setHasAttachments(false);
|
||||||
|
$message->setSyncedAt(new DateTimeImmutable());
|
||||||
|
$message->setSubject('Sujet de test');
|
||||||
|
$em->persist($message);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/mail/messages/'.$message->getId().'/create-task',
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
['CONTENT_TYPE' => 'application/json'],
|
||||||
|
json_encode([
|
||||||
|
'projectId' => $project->getId(),
|
||||||
|
'assigneeId' => $admin->getId(),
|
||||||
|
'statusId' => $status->getId(),
|
||||||
|
'priorityId' => 999, // doit être ignoré
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$payload = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$task = $em->getRepository(Task::class)->find($payload['taskId']);
|
||||||
|
self::assertSame($status->getId(), $task->getStatus()?->getId());
|
||||||
|
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
|
||||||
|
self::assertNull($task->getPriority());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user