Compare commits

...

30 Commits

Author SHA1 Message Date
5fb7fbe66c fix(workflow) : M4 - aligne la séquence workflow.id après recréation de l'identity (évite conflit avec row Standard de M1) 2026-05-19 20:59:37 +02:00
c1560468e6 fix(workflow) : WorkflowDrawer - input position natif (MalioInputText n'accepte pas les number) 2026-05-19 20:59:37 +02:00
f86698e7cd docs(workflows) : plan d'implémentation + validations Matthieu sur le spec + gitignore dumps locaux 2026-05-19 20:59:37 +02:00
1fd2c05db3 chore : bump version to v0.4.0 2026-05-19 20:59:37 +02:00
9f179e400d feat(workflow) : MCP - list-statuses projectId + list-workflows + switch-project-workflow + maj descriptions create/update-task 2026-05-19 20:59:12 +02:00
6a37349cf7 feat(workflow) : bulk status désactivé sur sélection multi-projets, scoped au workflow du projet 2026-05-19 20:59:12 +02:00
52b78d6bbc feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer 2026-05-19 20:59:12 +02:00
e6d765f7bb feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut, suppression drag-to-status 2026-05-19 20:59:12 +02:00
5d42009348 feat(workflow) : kanban projet et archives basés sur workflow.statuses du projet 2026-05-19 20:59:12 +02:00
8e4ddf00a8 feat(workflow) : admin UI - WorkflowDrawer + AdminWorkflowTab + remplacement onglet Statuts, suppression composants obsolètes 2026-05-19 20:59:12 +02:00
18bc96082f feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project + service + i18n 2026-05-19 20:59:12 +02:00
6a084489ea feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel 2026-05-19 20:59:12 +02:00
80a41db34f feat(workflow) : protège la suppression d'un workflow lié à des projets (409) 2026-05-19 20:59:12 +02:00
cf94635121 feat(workflow) : valide que task.status appartient au workflow du projet 2026-05-19 20:59:12 +02:00
eec61c089c feat(workflow) : migration M4 - alignement schéma Doctrine (indexes + IDENTITY) 2026-05-19 20:59:12 +02:00
a9f87be8e5 feat(workflow) : listener garantissant un seul workflow isDefault=true 2026-05-19 20:59:12 +02:00
25f2fc4b16 feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés 2026-05-19 20:59:12 +02:00
a21914312a feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
f6a947ec15 feat(workflow) : migration M2 - rattache les statuts existants à Standard + category 2026-05-19 20:59:12 +02:00
03f3c85fd8 feat(workflow) : migration M1 - création table workflow + seed Standard 2026-05-19 20:59:12 +02:00
8a68e0d397 feat(workflow) : ajoute workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
43e6d1aed2 feat(workflow) : ajoute workflow et category sur TaskStatus 2026-05-19 20:59:12 +02:00
a3e3fd6da6 feat(workflow) : ajoute l'entité Workflow et son repository 2026-05-19 20:59:12 +02:00
b8b03048b6 feat(workflow) : ajoute l'enum StatusCategory (5 catégories canoniques) 2026-05-19 20:59:12 +02:00
Matthieu
ba86a71e12 docs(workflows) : ajout note de reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
Matthieu
6a942def3f docs(workflows) : spec workflows de statuts par projet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
48 changed files with 5128 additions and 507 deletions

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@
###> docker local ### ###> docker local ###
infra/dev/.env.docker.local infra/dev/.env.docker.local
###< docker local ### ###< docker local ###
###> local db dumps ###
*.sql.gz
*.sql.gz:Zone.Identifier
REVIEW.md
###< local db dumps ###

View File

@@ -21,3 +21,6 @@ mcp:
store: file store: file
directory: '%kernel.project_dir%/var/mcp-sessions' directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600 ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.3.32' app.version: '0.4.0'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
# Workflows de statuts par projet (Kanban custom)
**Date** : 2026-05-19
**Branche** : `feat/project-workflows`
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
## Reprise sur un autre poste
> **Pour le prochain Claude qui ouvre cette branche :**
>
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
> 3. **Aucun code applicatif n'a encore été écrit.**
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
> 5. **Validations Matthieu (2026-05-19)** :
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
> - Suppression d'`AdminStatusTab` → **OK**.
> - Ordre des étapes de livraison (§10) → **OK**.
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
## 1. Contexte et besoin
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
## 2. Modèle de données
### Nouvelle entité : `Workflow`
```
Workflow
- id int, PK
- name string(255), unique
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
- position int (pour l'ordre dans l'admin)
- statuses OneToMany → TaskStatus (inverse côté Workflow)
```
### Modifications : `TaskStatus`
```
TaskStatus
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
```
### Modifications : `Project`
```
Project
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
```
### Choix de design
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
## 3. Migrations BDD
Trois migrations Doctrine successives :
**M1 — `create_workflow_table`**
- Crée la table `workflow` (id, name, is_default, position)
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
**M2 — `add_workflow_to_task_status`**
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
- "À faire" → `todo`
- "En cours" → `in_progress`
- "Bloqué" → `blocked`
- "En attente de validation" → `review`
- "Terminé" → `done`
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
- Passe les 2 colonnes en `NOT NULL`
**M3 — `add_workflow_to_project`**
- Ajoute `project.workflow_id` nullable
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
## 4. Backend (Symfony / API Platform)
### Entités
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
### Sérialisation
- Groupe `workflow:read` pour l'API admin
- `task_status:read` ajoute `workflow` et `category`
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
### Endpoint dédié au switch
```
POST /api/projects/{id}/switch-workflow
Body: {
workflowId: int,
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
}
Security: ROLE_ADMIN
```
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
3. Transaction unique :
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
- `UPDATE project SET workflow_id = <new>`
4. Retourne `{ project, migratedTaskCount }`
### Validation cross-entity
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
### Suppression d'un Workflow
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
## 5. Frontend (Nuxt / Vue)
### Nouveaux fichiers
- `frontend/services/workflows.ts` — service API CRUD
- `frontend/services/dto/workflow.ts` — type TS
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
### Modifications
- `frontend/components/admin/AdminStatusTab.vue` :
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
- `frontend/components/project/ProjectDrawer.vue` :
- Affiche le workflow actuel
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
- `frontend/pages/projects/[id]/index.vue` :
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
- Le kanban a les colonnes du workflow du projet
- `frontend/pages/projects/[id]/archives.vue` :
- Filtre statut limité au workflow du projet
- `frontend/pages/my-tasks.vue` :
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
- Chaque card affiche le statut spécifique en badge
- Vue liste : pas de changement
- `frontend/components/task/TaskModal.vue` :
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
- `frontend/components/task/TaskBulkActions.vue` :
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
### `ProjectWorkflowSwitchModal.vue` — détails UX
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
- Étape 2 (après sélection) : tableau de mapping
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
- Footer :
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
## 6. MCP
| Tool | Changement |
|---|---|
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
## 7. Permissions
| Action | Rôle requis |
|---|---|
| Lire les workflows et leurs statuts | `ROLE_USER` |
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
## 8. Hors scope (YAGNI explicites)
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
- **Versioning des workflows** (historique des modifs) — pas demandé
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
## 9. Risques et limites
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
## 10. Étapes de livraison suggérées
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
4. Service frontend `workflows` + types DTO
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
9. Adaptation `TaskBulkActions` et autres écrans transverses
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.

View File

@@ -1,140 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un statut"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun statut trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-color="{ item }">
<span
class="inline-block h-6 w-6 rounded-full"
:style="{ backgroundColor: item.color }"
/>
</template>
</DataTable>
<TaskStatusDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
<ConfirmDeleteStatusModal
v-model="confirmModalOpen"
:status-label="statusToDelete?.label ?? ''"
:task-count="affectedTaskCount"
:available-statuses="reassignTargets"
@confirm="onConfirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import type { Task } from '~/services/dto/task'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskService } from '~/services/tasks'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'label', label: 'Libellé', primary: true },
{ key: 'color', label: 'Couleur' },
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
]
const statusService = useTaskStatusService()
const taskService = useTaskService()
const items = ref<TaskStatus[]>([])
const tasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)
const confirmModalOpen = ref(false)
const statusToDelete = ref<TaskStatus | null>(null)
const affectedTaskCount = computed(() => {
if (!statusToDelete.value) return 0
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
})
const reassignTargets = computed(() => {
if (!statusToDelete.value) return items.value
return items.value.filter(s => s.id !== statusToDelete.value!.id)
})
async function loadItems() {
isLoading.value = true
try {
const [statuses, allTasks] = await Promise.all([
statusService.getAll(),
taskService.getAll(),
])
items.value = statuses
tasks.value = allTasks
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: TaskStatus) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: TaskStatus) {
statusToDelete.value = item
const count = tasks.value.filter(t => t.status?.id === item.id).length
if (count === 0) {
await statusService.remove(item.id)
await loadItems()
} else {
confirmModalOpen.value = true
}
}
async function onConfirmDelete(targetStatusId: number | null) {
if (!statusToDelete.value) return
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
await Promise.all(
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
)
await statusService.remove(statusToDelete.value.id)
confirmModalOpen.value = false
statusToDelete.value = null
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('workflows.addWorkflow')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun workflow trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-isDefault="{ item }">
<span
v-if="item.isDefault"
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
>
{{ $t('workflows.isDefault') }}
</span>
</template>
<template #cell-statusCount="{ item }">
{{ item.statuses.length }}
</template>
</DataTable>
<WorkflowDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns: DataTableColumn[] = [
{ key: 'name', label: t('workflows.name'), primary: true },
{ key: 'isDefault', label: t('workflows.isDefault') },
{ key: 'statusCount', label: t('workflows.statuses') },
{ key: 'position', label: 'Position' },
]
const workflowService = useWorkflowService()
const items = ref<Workflow[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Workflow | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await workflowService.getAll()
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Workflow) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: Workflow) {
try {
await workflowService.remove(item.id)
await loadItems()
} catch {
// Toast d'erreur déjà émis par useApi
}
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -0,0 +1,261 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.name"
:label="$t('workflows.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
@blur="touched.name = true"
/>
<div class="flex items-center gap-2">
<input
id="isDefault"
v-model="form.isDefault"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isDefault" class="text-sm font-medium text-neutral-700">
{{ $t('workflows.isDefault') }}
</label>
</div>
<div class="mt-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
<MalioButton
type="button"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.addStatus')"
@click="addStatus"
/>
</div>
<div class="mt-3 flex flex-col gap-3">
<div
v-for="(s, idx) in form.statuses"
:key="idx"
class="rounded border border-neutral-200 p-3"
>
<div class="flex items-end gap-2">
<MalioInputText
v-model="s.label"
label="Libellé"
input-class="w-full"
/>
<select
v-model="s.category"
class="h-10 rounded border border-neutral-300 px-2 text-sm"
aria-label="Catégorie"
>
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</option>
</select>
<button
type="button"
class="h-10 px-2 text-red-600 hover:text-red-800"
aria-label="Supprimer"
@click="removeStatus(idx)"
>
<Icon name="mdi:delete" size="20" />
</button>
</div>
<div class="mt-2 flex items-center gap-3">
<ColorPicker v-model="s.color" />
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
{{ $t('archive.statusFinal') }}
</label>
<label class="flex flex-col text-xs text-neutral-700">
Position
<input
v-model.number="s.position"
type="number"
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
/>
</label>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
item: Workflow | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
type StatusForm = {
id?: number
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
}
const form = reactive<{
name: string
isDefault: boolean
statuses: StatusForm[]
}>({
name: '',
isDefault: false,
statuses: [],
})
const touched = reactive({ name: false })
const categoryOptions: { value: StatusCategory, label: string }[] = [
{ value: 'todo', label: t('workflows.categories.todo') },
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
{ value: 'blocked', label: t('workflows.categories.blocked') },
{ value: 'review', label: t('workflows.categories.review') },
{ value: 'done', label: t('workflows.categories.done') },
]
watch(() => props.modelValue, (open) => {
if (!open) return
if (props.item) {
form.name = props.item.name
form.isDefault = props.item.isDefault
form.statuses = props.item.statuses.map(s => ({
id: s.id,
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
}))
} else {
form.name = ''
form.isDefault = false
form.statuses = []
}
touched.name = false
})
function addStatus() {
form.statuses.push({
label: '',
color: '#222783',
position: form.statuses.length,
isFinal: false,
category: 'todo',
})
}
function removeStatus(idx: number) {
form.statuses.splice(idx, 1)
}
const workflowService = useWorkflowService()
const statusService = useTaskStatusService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
await workflowService.update(props.item.id, {
name: form.name.trim(),
isDefault: form.isDefault,
position: props.item.position,
})
await syncStatuses(props.item)
} else {
const created = await workflowService.create({
name: form.name.trim(),
isDefault: form.isDefault,
position: 0,
})
for (const s of form.statuses) {
const payload: TaskStatusWrite = {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${created.id}`,
}
await statusService.create(payload)
}
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function syncStatuses(workflow: Workflow) {
const existingIds = new Set(workflow.statuses.map(s => s.id))
const keptIds = new Set<number>()
for (const s of form.statuses) {
if (s.id) {
keptIds.add(s.id)
await statusService.update(s.id, {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
})
} else {
await statusService.create({
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${workflow.id}`,
})
}
}
for (const id of existingIds) {
if (id && !keptIds.has(id)) {
await statusService.remove(id)
}
}
}
</script>

View File

@@ -87,10 +87,35 @@
</MalioButton> </MalioButton>
</div> </div>
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
</div>
<MalioButton
v-if="canManageWorkflows"
type="button"
icon-name="mdi:swap-horizontal"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.switchTitle')"
@click="switchModalOpen = true"
/>
</div>
</div>
<ConfirmDeleteProjectModal <ConfirmDeleteProjectModal
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<ProjectWorkflowSwitchModal
v-if="props.project"
v-model="switchModalOpen"
:project="props.project"
@switched="onWorkflowSwitched"
/>
</MalioDrawer> </MalioDrawer>
</template> </template>
@@ -122,6 +147,15 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project) const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false) const confirmDeleteOpen = ref(false)
const switchModalOpen = ref(false)
const auth = useAuthStore()
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
function onWorkflowSwitched() {
emit('saved')
isOpen.value = false
}
const { listRepositories } = useGiteaService() const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([]) const giteaRepos = ref<GiteaRepository[]>([])

View File

@@ -0,0 +1,209 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="close" />
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
<div class="mt-5 flex flex-col gap-5">
<MalioSelect
v-model="targetWorkflowId"
:options="targetOptions"
:label="$t('workflows.switchTargetLabel')"
empty-option-label=""
min-width="!w-full"
/>
<div v-if="targetWorkflow" class="flex flex-col gap-2">
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-neutral-500">
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
<td class="py-2 pr-3">
<span
v-if="row.source"
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
:style="{ backgroundColor: row.source.color }"
/>
{{ row.source?.label ?? $t('myTasks.backlog') }}
<span class="ml-1 text-xs text-neutral-400">
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
</span>
</td>
<td class="py-2 pr-3">
<select
v-model="row.targetId"
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
>
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
<option
v-for="s in targetWorkflow.statuses"
:key="s.id"
:value="s.id"
>
{{ s.label }}
</option>
</select>
</td>
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="$t('workflows.switchConfirm')"
button-class="w-auto px-6"
:disabled="!canConfirm || isSubmitting"
@click="confirm"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
project: Project
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'switched'): void
}>()
const workflows = ref<Workflow[]>([])
const projectTasks = ref<Task[]>([])
const targetWorkflowId = ref<number | null>(null)
const isSubmitting = ref(false)
const workflowService = useWorkflowService()
const taskService = useTaskService()
const targetOptions = computed(() =>
workflows.value
.filter(w => w.id !== props.project.workflow.id)
.map(w => ({ label: w.name, value: w.id })),
)
const targetWorkflow = computed<Workflow | null>(() =>
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
)
type Row = {
sourceId: number | null
source: TaskStatus | null
targetId: number | null
count: number
}
const mappingRows = ref<Row[]>([])
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
if (!source) return null
const sameCat = target.statuses
.filter(s => s.category === source.category)
.sort((a, b) => a.position - b.position)
return sameCat[0]?.id ?? null
}
watch(targetWorkflow, (tw) => {
if (!tw) {
mappingRows.value = []
return
}
const usedStatusIds = new Map<number | null, number>()
for (const t of projectTasks.value) {
const key = t.status?.id ?? null
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
}
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
return {
sourceId,
source,
targetId: smartPrefill(source, tw),
count,
}
})
})
const canConfirm = computed(() => {
if (!targetWorkflow.value) return false
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
})
watch(() => props.modelValue, async (open) => {
if (!open) return
targetWorkflowId.value = null
const [allWorkflows, tasks] = await Promise.all([
workflowService.getAll(),
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
])
workflows.value = allWorkflows
projectTasks.value = tasks
})
function close() {
emit('update:modelValue', false)
}
async function confirm() {
if (!targetWorkflow.value) return
isSubmitting.value = true
try {
const mapping: Record<string, number | null> = {}
for (const r of mappingRows.value) {
if (r.sourceId !== null) {
mapping[String(r.sourceId)] = r.targetId
}
}
await workflowService.switchOnProject(props.project.id, {
workflowId: targetWorkflow.value.id,
mapping,
})
emit('switched')
close()
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.15s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -14,8 +14,9 @@
</span> </span>
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1"> <div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
<!-- Bulk status --> <!-- Bulk status (scoped to single project's workflow) -->
<MalioSelect <MalioSelect
v-if="!isMultiProject"
:model-value="null" :model-value="null"
:options="statusOptions" :options="statusOptions"
label="Status" label="Status"
@@ -25,6 +26,13 @@
text-value="text-xs" text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)" @update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
/> />
<span
v-else
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
title="Sélection multi-projets le statut dépend du workflow de chaque projet"
>
Status —
</span>
<!-- Bulk user --> <!-- Bulk user -->
<MalioSelect <MalioSelect
:model-value="null" :model-value="null"
@@ -85,13 +93,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group' 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'
const props = defineProps<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
totalCount: number totalCount: number
allSelected: boolean allSelected: boolean
@@ -101,7 +111,12 @@ const props = defineProps<{
priorities: TaskPriority[] priorities: TaskPriority[]
efforts: TaskEffort[] efforts: TaskEffort[]
groups: TaskGroup[] groups: TaskGroup[]
}>() selectedTasks?: Task[]
projects?: Project[]
}>(), {
selectedTasks: () => [],
projects: () => [],
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toggle-all'): void (e: 'toggle-all'): void
@@ -110,23 +125,42 @@ const emit = defineEmits<{
(e: 'bulk-delete'): void (e: 'bulk-delete'): void
}>() }>()
const statusOptions = computed(() => const distinctProjectIds = computed(() => {
props.statuses.map(s => ({ label: s.label, value: s.id })) const ids = new Set<number>()
) for (const t of props.selectedTasks) {
if (t.project) ids.add(t.project.id)
}
return ids
})
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
const statusOptions = computed<{ label: string, value: number }[]>(() => {
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
const projectId = [...distinctProjectIds.value][0]
const project = props.projects.find(p => p.id === projectId)
if (project?.workflow?.statuses) {
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
}
}
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
return props.statuses.map(s => ({ label: s.label, value: s.id }))
})
const userOptions = computed(() => const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id })) props.users.map(u => ({ label: u.username, value: u.id })),
) )
const priorityOptions = computed(() => const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id })) props.priorities.map(p => ({ label: p.label, value: p.id })),
) )
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 })),
) )
const groupOptions = computed(() => const groupOptions = computed(() =>
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })) props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
) )
</script> </script>

View File

@@ -40,6 +40,13 @@
</div> </div>
<div class="mt-2 flex items-center gap-1.5"> <div class="mt-2 flex items-center gap-1.5">
<span
v-if="showStatusBadge && task.status"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
:style="{ backgroundColor: task.status.color }"
>
{{ task.status.label }}
</span>
<span <span
v-if="task.priority" v-if="task.priority"
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"
@@ -106,8 +113,10 @@ import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
showProjectColor?: boolean showProjectColor?: boolean
showStatusBadge?: boolean
}>(), { }>(), {
showProjectColor: false, showProjectColor: false,
showStatusBadge: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -1,122 +0,0 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
label="Libellé"
input-class="w-full"
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
@blur="touched.label = true"
/>
<MalioInputText
v-model="form.position"
label="Position"
input-class="w-full"
type="number"
/>
<div class="mt-4">
<ColorPicker v-model="form.color" />
</div>
<div class="mt-4 flex items-center gap-2">
<input
id="isFinal"
v-model="form.isFinal"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isFinal" class="text-sm font-medium text-neutral-700">
{{ $t('archive.statusFinal') }}
</label>
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
import { useTaskStatusService } from '~/services/task-statuses'
const props = defineProps<{
modelValue: boolean
item: TaskStatus | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
label: '',
position: '0',
color: '#222783',
isFinal: false,
})
const touched = reactive({
label: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.label = props.item.label ?? ''
form.position = String(props.item.position ?? 0)
form.color = props.item.color ?? '#222783'
form.isFinal = props.item.isFinal ?? false
} else {
form.label = ''
form.position = '0'
form.color = '#222783'
form.isFinal = false
}
touched.label = false
}
})
const { create, update } = useTaskStatusService()
async function handleSubmit() {
touched.label = true
if (!form.label.trim()) return
isSubmitting.value = true
try {
const payload: TaskStatusWrite = {
label: form.label.trim(),
position: Number(form.position),
color: form.color,
isFinal: form.isFinal,
}
if (isEditing.value && props.item) {
await update(props.item.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -1,93 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="isProcessing"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
const props = defineProps<{
modelValue: boolean
statusLabel: string
taskCount: number
availableStatuses: TaskStatus[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', targetStatusId: number | null): void
}>()
const targetStatusId = ref<number | null>(null)
const isProcessing = ref(false)
const targetOptions = computed(() =>
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
)
watch(() => props.modelValue, (open) => {
if (open) {
targetStatusId.value = null
isProcessing.value = false
}
})
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
isProcessing.value = true
emit('confirm', targetStatusId.value)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -56,6 +56,37 @@
"moveTo": "Déplacer vers", "moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)" "backlog": "Backlog (sans statut)"
}, },
"workflows": {
"title": "Workflows",
"addWorkflow": "Ajouter un workflow",
"editWorkflow": "Modifier le workflow",
"name": "Nom",
"isDefault": "Workflow par défaut",
"statuses": "Statuts",
"addStatus": "Ajouter un statut",
"category": "Catégorie",
"created": "Workflow créé",
"updated": "Workflow mis à jour",
"deleted": "Workflow supprimé",
"switched": "Workflow du projet changé",
"switchTitle": "Changer de workflow",
"switchTargetLabel": "Nouveau workflow",
"switchMappingTitle": "Mapping des statuts",
"switchSourceCol": "Statut actuel",
"switchTargetCol": "Statut cible",
"switchTaskCountCol": "Tâches",
"switchToBacklog": "Mapper vers le backlog",
"switchConfirm": "Confirmer la migration",
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
"categories": {
"todo": "À faire",
"in_progress": "En cours",
"blocked": "Bloqué",
"review": "En validation",
"done": "Terminé"
}
},
"taskEfforts": { "taskEfforts": {
"created": "Effort créé avec succès.", "created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.", "updated": "Effort mis à jour avec succès.",
@@ -393,7 +424,21 @@
"title": "Mon profil", "title": "Mon profil",
"changeAvatar": "Changer l'avatar", "changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar", "removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar" "cropAvatar": "Recadrer l'avatar",
"apiToken": {
"title": "Token API MCP",
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
"label": "Token",
"empty": "Aucun token généré pour le moment.",
"generate": "Générer un token",
"regenerate": "Régénérer",
"copy": "Copier",
"copied": "Token copié dans le presse-papiers.",
"copyFailed": "Impossible de copier le token.",
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
"confirmTitle": "Régénérer le token MCP ?",
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
}
}, },
"bookstack": { "bookstack": {
"settings": { "settings": {

View File

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

View File

@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group' 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 { STATUS_CATEGORY_LABEL } 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'
@@ -60,6 +62,7 @@ const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection // Bulk selection
const selectedTaskIds = reactive(new Set<number>()) const selectedTaskIds = reactive(new Set<number>())
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
// Modal // Modal
const taskModalOpen = ref(false) const taskModalOpen = ref(false)
@@ -112,13 +115,11 @@ const sortOptions = computed(() => [
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED }, { label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
]) ])
// Kanban helpers // Kanban helpers (grouped by canonical status category)
const sortedStatuses = computed(() => const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
[...statuses.value].sort((a, b) => a.position - b.position)
)
function tasksByStatus(statusId: number): Task[] { function tasksByCategory(category: StatusCategory): Task[] {
return tasks.value.filter(t => t.status?.id === statusId) return tasks.value.filter(t => t.status?.category === category)
} }
const backlogTasks = computed(() => const backlogTasks = computed(() =>
@@ -205,44 +206,6 @@ watch(selectedProjectId, () => {
selectedGroupId.value = null selectedGroupId.value = null
}, { flush: 'sync' }) }, { flush: 'sync' })
// Drag & drop
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
function onDragEnter(id: number) {
dragCounter.value++
dragOverStatusId.value = id
}
function onDragLeave() {
dragCounter.value--
if (dragCounter.value === 0) {
dragOverStatusId.value = null
}
}
function onDrop(event: DragEvent) {
dragCounter.value = 0
dragOverStatusId.value = null
return Number(event.dataTransfer!.getData('text/plain'))
}
async function onDropStatus(event: DragEvent, status: TaskStatus) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || task.status?.id === status.id) return
task.status = status
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
}
async function onDropBacklog(event: DragEvent) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || !task.status) return
task.status = null
await taskService.update(taskId, { status: null })
}
// Modal // Modal
function openTaskCreate() { function openTaskCreate() {
selectedTask.value = null selectedTask.value = null
@@ -428,36 +391,29 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Kanban View --> <!-- Kanban View grouped by canonical category -->
<div v-if="viewMode === 'kanban'"> <div v-if="viewMode === 'kanban'">
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4"> <div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div <div
v-for="status in sortedStatuses" v-for="cat in CATEGORIES"
:key="status.id" :key="cat"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors" class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@dragleave="onDragLeave"
@drop.prevent="onDropStatus($event, status)"
> >
<div <div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white" {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).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">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<TaskCard <TaskCard
v-for="task in tasksByStatus(status.id)" v-for="task in tasksByCategory(cat)"
:key="task.id" :key="task.id"
:task="task" :task="task"
show-project-color show-project-color
show-status-badge
@click="openTaskEdit(task)" @click="openTaskEdit(task)"
/> />
<p <p
v-if="tasksByStatus(status.id).length === 0" v-if="tasksByCategory(cat).length === 0"
class="py-4 text-center text-xs text-neutral-400" class="py-4 text-center text-xs text-neutral-400"
> >
{{ $t('myTasks.noTasks') }} {{ $t('myTasks.noTasks') }}
@@ -467,15 +423,8 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Backlog below kanban --> <!-- Backlog below kanban (no drag/drop status change goes through TaskModal) -->
<div <div class="mt-8 rounded-lg bg-neutral-50 p-4">
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(0)"
@dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)"
>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2> <h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<TaskCard <TaskCard
@@ -483,6 +432,7 @@ onMounted(async () => {
:key="task.id" :key="task.id"
:task="task" :task="task"
show-project-color show-project-color
show-status-badge
@click="openTaskEdit(task)" @click="openTaskEdit(task)"
/> />
</div> </div>
@@ -507,6 +457,8 @@ onMounted(async () => {
:priorities="priorities" :priorities="priorities"
:efforts="efforts" :efforts="efforts"
:groups="groups" :groups="groups"
:selected-tasks="selectedTasksArray"
:projects="projects"
@toggle-all="toggleSelectAll(tasks)" @toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate" @bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive" @bulk-archive="onBulkArchive"

View File

@@ -37,6 +37,56 @@
</div> </div>
</div> </div>
<!-- API Token MCP (interne uniquement) -->
<div
v-if="!isClientOnly"
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
>
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
<div v-if="auth.user?.apiToken">
<MalioInputPassword
:model-value="auth.user.apiToken"
:label="$t('profile.apiToken.label')"
readonly
@update:model-value="() => {}"
/>
<div class="mt-3 flex flex-wrap gap-3">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
icon-name="mdi:content-copy"
icon-position="left"
:label="$t('profile.apiToken.copy')"
@click="onCopy"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
icon-name="mdi:refresh"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="showConfirm = true"
/>
</div>
</div>
<div v-else>
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
<MalioButton
variant="primary"
button-class="w-auto px-4"
icon-name="mdi:key-plus"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.generate')"
@click="onRegenerate"
/>
</div>
</div>
<!-- Crop modal --> <!-- Crop modal -->
<AvatarCropper <AvatarCropper
v-if="selectedFile" v-if="selectedFile"
@@ -44,14 +94,45 @@
@crop="onCrop" @crop="onCrop"
@cancel="selectedFile = null" @cancel="selectedFile = null"
/> />
<!-- Confirm regenerate modal -->
<Teleport v-if="showConfirm" to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('profile.apiToken.confirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="showConfirm = false"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="onRegenerate"
/>
</div>
</div>
</div>
</Teleport>
</div> </div>
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const { t } = useI18n()
const isClientOnly = computed(() => const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN') auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
@@ -61,9 +142,12 @@ definePageMeta({
layout: false, layout: false,
}) })
const { upload, remove } = useAvatarService() const { upload, remove } = useAvatarService()
const { regenerate } = useApiTokenService()
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)
const removing = ref(false) const removing = ref(false)
const regenerating = ref(false)
const showConfirm = ref(false)
function onFileSelect(event: Event) { function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -97,4 +181,28 @@ async function onRemove() {
removing.value = false removing.value = false
} }
} }
async function onCopy() {
if (!auth.user?.apiToken) return
try {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') })
} catch {
toast.error({ message: t('profile.apiToken.copyFailed') })
}
}
async function onRegenerate() {
regenerating.value = true
try {
const newToken = await regenerate()
if (auth.user) {
auth.user.apiToken = newToken
}
showConfirm.value = false
toast.success({ message: t('profile.apiToken.regenerated') })
} finally {
regenerating.value = false
}
}
</script> </script>

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
export function useApiTokenService() {
const api = useApi()
async function regenerate(): Promise<string> {
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
toast: false,
})
return data.apiToken
}
return { regenerate }
}

View File

@@ -1,4 +1,5 @@
import type { Client } from './client' import type { Client } from './client'
import type { Workflow } from './workflow'
export type Project = { export type Project = {
id: number id: number
@@ -8,6 +9,7 @@ export type Project = {
description: string | null description: string | null
color: string color: string
client: Client | null client: Client | null
workflow: Workflow
giteaOwner: string | null giteaOwner: string | null
giteaRepo: string | null giteaRepo: string | null
bookstackShelfId: number | null bookstackShelfId: number | null
@@ -22,6 +24,7 @@ export type ProjectWrite = {
description: string | null description: string | null
color: string color: string
client: string | null // IRI : "/api/clients/1" ou null client: string | null // IRI : "/api/clients/1" ou null
workflow?: string // IRI : "/api/workflows/1"
giteaOwner?: string | null giteaOwner?: string | null
giteaRepo?: string | null giteaRepo?: string | null
bookstackShelfId?: number | null bookstackShelfId?: number | null

View File

@@ -1,3 +1,5 @@
import type { StatusCategory } from './workflow'
export type TaskStatus = { export type TaskStatus = {
id: number id: number
'@id'?: string '@id'?: string
@@ -5,6 +7,8 @@ export type TaskStatus = {
color: string color: string
position: number position: number
isFinal: boolean isFinal: boolean
category: StatusCategory
workflow?: { '@id': string, id: number } | string
} }
export type TaskStatusWrite = { export type TaskStatusWrite = {
@@ -12,4 +16,6 @@ export type TaskStatusWrite = {
color: string color: string
position: number position: number
isFinal: boolean isFinal: boolean
category: StatusCategory
workflow?: string
} }

View File

@@ -8,6 +8,7 @@ export type UserData = {
client?: { id: number; name: string } | null client?: { id: number; name: string } | null
allowedProjects?: Project[] allowedProjects?: Project[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null
} }
export type UserWrite = { export type UserWrite = {

View File

@@ -0,0 +1,27 @@
import type { TaskStatus, TaskStatusWrite } from './task-status'
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
todo: 'À faire',
in_progress: 'En cours',
blocked: 'Bloqué',
review: 'En validation',
done: 'Terminé',
}
export type Workflow = {
id: number
'@id'?: string
name: string
isDefault: boolean
position: number
statuses: TaskStatus[]
}
export type WorkflowWrite = {
name: string
isDefault: boolean
position: number
statuses?: TaskStatusWrite[]
}

View File

@@ -0,0 +1,55 @@
import type { Workflow, WorkflowWrite } from './dto/workflow'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type SwitchPayload = {
workflowId: number
mapping: Record<string, number | null>
}
type SwitchResult = {
projectId: number
workflowId: number
migratedTaskCount: number
}
export function useWorkflowService() {
const api = useApi()
async function getAll(): Promise<Workflow[]> {
const data = await api.get<HydraCollection<Workflow>>('/workflows')
return extractHydraMembers(data)
}
async function getOne(id: number): Promise<Workflow> {
return api.get<Workflow>(`/workflows/${id}`)
}
async function create(payload: WorkflowWrite): Promise<Workflow> {
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.created',
})
}
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/workflows/${id}`, {}, {
toastSuccessKey: 'workflows.deleted',
})
}
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
return api.post<SwitchResult>(
`/projects/${projectId}/switch-workflow`,
payload as unknown as Record<string, unknown>,
{ toastSuccessKey: 'workflows.switched' },
)
}
return { getAll, getOne, create, update, remove, switchOnProject }
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519175041 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create workflow table and seed default Standard workflow';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE workflow (
id SERIAL NOT NULL,
name VARCHAR(255) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)');
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_name ON workflow (name)');
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE is_default = TRUE');
$this->addSql("INSERT INTO workflow (name, is_default, position) VALUES ('Standard', TRUE, 0)");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE workflow');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;
final class Version20260519175114 extends AbstractMigration
{
public function getDescription(): string
{
return 'Attach existing TaskStatus rows to Standard workflow and backfill category (strict mapping)';
}
public function up(Schema $schema): void
{
// 1) Récupérer l'id du workflow Standard
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
if (!$standardId) {
throw new MigrationException('Workflow Standard introuvable. Lancer M1 d\'abord.');
}
// 2) Garde-fou : vérifier qu'il n'y a pas de label hors mapping
$mapping = [
'A faire' => 'todo',
'À faire' => 'todo',
'En cours' => 'in_progress',
'Bloqué' => 'blocked',
'En attente de validation' => 'review',
'Terminé' => 'done',
];
$rows = $this->connection->fetchAllAssociative('SELECT id, label FROM task_status');
foreach ($rows as $row) {
if (!isset($mapping[$row['label']])) {
throw new MigrationException(sprintf(
'TaskStatus #%d ("%s") n\'est pas mappable. Ajoutez son mapping dans la migration avant de relancer.',
$row['id'],
$row['label'],
));
}
}
// 3) Ajouter colonnes nullable
$this->addSql('ALTER TABLE task_status ADD COLUMN workflow_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task_status ADD COLUMN category VARCHAR(32) DEFAULT NULL');
// 4) Backfill
$this->addSql("UPDATE task_status SET workflow_id = {$standardId}");
foreach ($mapping as $label => $cat) {
$this->addSql(sprintf(
"UPDATE task_status SET category = '%s' WHERE label = '%s'",
$cat,
str_replace("'", "''", $label),
));
}
// 5) NOT NULL + FK
$this->addSql('ALTER TABLE task_status ALTER COLUMN workflow_id SET NOT NULL');
$this->addSql('ALTER TABLE task_status ALTER COLUMN category SET NOT NULL');
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_task_status_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_task_status_workflow ON task_status (workflow_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_task_status_workflow');
$this->addSql('DROP INDEX IDX_task_status_workflow');
$this->addSql('ALTER TABLE task_status DROP COLUMN workflow_id');
$this->addSql('ALTER TABLE task_status DROP COLUMN category');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;
final class Version20260519175142 extends AbstractMigration
{
public function getDescription(): string
{
return 'Attach existing projects to Standard workflow (NOT NULL, RESTRICT)';
}
public function up(Schema $schema): void
{
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
if (!$standardId) {
throw new MigrationException('Workflow Standard introuvable.');
}
$this->addSql('ALTER TABLE project ADD COLUMN workflow_id INT DEFAULT NULL');
$this->addSql("UPDATE project SET workflow_id = {$standardId}");
$this->addSql('ALTER TABLE project ALTER COLUMN workflow_id SET NOT NULL');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_project_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE RESTRICT NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_project_workflow ON project (workflow_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_project_workflow');
$this->addSql('DROP INDEX IDX_project_workflow');
$this->addSql('ALTER TABLE project DROP COLUMN workflow_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519175338 extends AbstractMigration
{
public function getDescription(): string
{
return 'Align workflow schema with Doctrine naming (indexes, IDENTITY). Drops the partial unique index uniq_workflow_one_default (uniqueness is enforced by UniqueDefaultWorkflowListener at app level).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER INDEX idx_project_workflow RENAME TO IDX_2FB3D0EE2C7C2CBA');
$this->addSql('ALTER INDEX idx_task_status_workflow RENAME TO IDX_40A9E1CF2C7C2CBA');
$this->addSql('DROP INDEX uniq_workflow_one_default');
$this->addSql('ALTER TABLE workflow ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE workflow ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
// Aligner la séquence d'identity sur MAX(id) pour éviter le conflit avec les rows déjà insérés par M1
$this->addSql('SELECT setval(pg_get_serial_sequence(\'workflow\', \'id\'), COALESCE((SELECT MAX(id) FROM workflow), 0) + 1, false)');
$this->addSql('ALTER INDEX uniq_workflow_name RENAME TO UNIQ_65C598165E237E06');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER INDEX idx_2fb3d0ee2c7c2cba RENAME TO idx_project_workflow');
$this->addSql('ALTER INDEX idx_40a9e1cf2c7c2cba RENAME TO idx_task_status_workflow');
$this->addSql('ALTER TABLE workflow ALTER id DROP IDENTITY');
$this->addSql("ALTER TABLE workflow ALTER id SET DEFAULT nextval('workflow_id_seq'::regclass)");
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE (is_default = true)');
$this->addSql('ALTER INDEX uniq_65c598165e237e06 RENAME TO uniq_workflow_name');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;
final class SwitchWorkflowOutput
{
#[Groups(['switch_workflow:read'])]
public int $projectId;
#[Groups(['switch_workflow:read'])]
public int $workflowId;
#[Groups(['switch_workflow:read'])]
public int $migratedTaskCount;
public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
{
$this->projectId = $projectId;
$this->workflowId = $workflowId;
$this->migratedTaskCount = $migratedTaskCount;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function bin2hex;
use function random_bytes;
class RegenerateApiTokenController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$token = bin2hex(random_bytes(32));
$user->setApiToken($token);
$this->entityManager->flush();
return new JsonResponse(['apiToken' => $token]);
}
}

View File

@@ -16,8 +16,10 @@ use App\Entity\TaskStatus;
use App\Entity\TaskTag; use App\Entity\TaskTag;
use App\Entity\TimeEntry; use App\Entity\TimeEntry;
use App\Entity\User; use App\Entity\User;
use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration; use App\Entity\ZimbraConfiguration;
use App\Enum\RecurrenceType; use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -86,57 +88,31 @@ class AppFixtures extends Fixture
$clientNova->setPostalCode('69007'); $clientNova->setPostalCode('69007');
$manager->persist($clientNova); $manager->persist($clientNova);
// Projets // Workflow par défaut
$projectSirh = new Project(); $standardWorkflow = new Workflow();
$projectSirh->setCode('SIRH'); $standardWorkflow->setName('Standard');
$projectSirh->setName('SIRH'); $standardWorkflow->setIsDefault(true);
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.'); $standardWorkflow->setPosition(0);
$projectSirh->setColor('#222783'); $manager->persist($standardWorkflow);
$projectSirh->setClient($clientLiot);
$manager->persist($projectSirh);
$projectCrm = new Project(); // Task Statuses (rattachés au workflow Standard)
$projectCrm->setCode('CRM');
$projectCrm->setName('CRM');
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
$projectCrm->setColor('#E91E63');
$projectCrm->setClient($clientAcme);
$manager->persist($projectCrm);
$projectErp = new Project();
$projectErp->setCode('ERP');
$projectErp->setName('ERP');
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
$projectErp->setColor('#4A90D9');
$projectErp->setClient($clientNova);
$manager->persist($projectErp);
$projectInterne = new Project();
$projectInterne->setCode('SITE');
$projectInterne->setName('Site vitrine');
$projectInterne->setDescription('Refonte du site web corporate.');
$projectInterne->setColor('#26A69A');
$projectInterne->setClient(null);
$manager->persist($projectInterne);
// Task Statuses (global)
$defaultStatuses = [ $defaultStatuses = [
['A faire', '#222783', 0], ['A faire', '#222783', 0, StatusCategory::Todo, false],
['En cours', '#4A90D9', 1], ['En cours', '#4A90D9', 1, StatusCategory::InProgress, false],
['Bloqué', '#C62828', 2], ['Bloqué', '#C62828', 2, StatusCategory::Blocked, false],
['En attente de validation', '#FF8F00', 3], ['En attente de validation', '#FF8F00', 3, StatusCategory::Review, false],
['Terminé', '#26A69A', 4], ['Terminé', '#26A69A', 4, StatusCategory::Done, true],
]; ];
$statusObjects = []; $statusObjects = [];
foreach ($defaultStatuses as [$label, $color, $position]) { foreach ($defaultStatuses as [$label, $color, $position, $category, $isFinal]) {
$status = new TaskStatus(); $status = new TaskStatus();
$status->setLabel($label); $status->setLabel($label);
$status->setColor($color); $status->setColor($color);
$status->setPosition($position); $status->setPosition($position);
if ('Terminé' === $label) { $status->setCategory($category);
$status->setIsFinal(true); $status->setIsFinal($isFinal);
} $standardWorkflow->addStatus($status);
$manager->persist($status); $manager->persist($status);
$statusObjects[$label] = $status; $statusObjects[$label] = $status;
} }
@@ -147,6 +123,43 @@ class AppFixtures extends Fixture
$statusReview = $statusObjects['En attente de validation']; $statusReview = $statusObjects['En attente de validation'];
$statusDone = $statusObjects['Terminé']; $statusDone = $statusObjects['Terminé'];
// Projets
$projectSirh = new Project();
$projectSirh->setCode('SIRH');
$projectSirh->setName('SIRH');
$projectSirh->setDescription('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer ac blandit turpis.');
$projectSirh->setColor('#222783');
$projectSirh->setClient($clientLiot);
$projectSirh->setWorkflow($standardWorkflow);
$manager->persist($projectSirh);
$projectCrm = new Project();
$projectCrm->setCode('CRM');
$projectCrm->setName('CRM');
$projectCrm->setDescription('Gestion de la relation client et suivi commercial.');
$projectCrm->setColor('#E91E63');
$projectCrm->setClient($clientAcme);
$projectCrm->setWorkflow($standardWorkflow);
$manager->persist($projectCrm);
$projectErp = new Project();
$projectErp->setCode('ERP');
$projectErp->setName('ERP');
$projectErp->setDescription('Planification des ressources et gestion des stocks.');
$projectErp->setColor('#4A90D9');
$projectErp->setClient($clientNova);
$projectErp->setWorkflow($standardWorkflow);
$manager->persist($projectErp);
$projectInterne = new Project();
$projectInterne->setCode('SITE');
$projectInterne->setName('Site vitrine');
$projectInterne->setDescription('Refonte du site web corporate.');
$projectInterne->setColor('#26A69A');
$projectInterne->setClient(null);
$projectInterne->setWorkflow($standardWorkflow);
$manager->persist($projectInterne);
// Task Efforts // Task Efforts
$effortS = new TaskEffort(); $effortS = new TaskEffort();
$effortS->setLabel('S'); $effortS->setLabel('S');

View File

@@ -10,9 +10,12 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\ApiResource\SwitchWorkflowOutput;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\State\SwitchProjectWorkflowProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -30,6 +33,19 @@ use Symfony\Component\Validator\Constraints as Assert;
), ),
new Patch(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post(
uriTemplate: '/projects/{id}/switch-workflow',
uriVariables: ['id' => new Link(fromClass: Project::class)],
security: "is_granted('ROLE_ADMIN')",
input: false,
output: SwitchWorkflowOutput::class,
normalizationContext: ['groups' => ['switch_workflow:read']],
processor: SwitchProjectWorkflowProcessor::class,
read: true,
deserialize: false,
validate: false,
name: 'switch_workflow',
),
], ],
normalizationContext: ['groups' => ['project:read']], normalizationContext: ['groups' => ['project:read']],
denormalizationContext: ['groups' => ['project:write']], denormalizationContext: ['groups' => ['project:write']],
@@ -69,6 +85,12 @@ class Project
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private ?Client $client = null; private ?Client $client = null;
#[ORM\ManyToOne(targetEntity: Workflow::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
#[Groups(['project:read', 'project:write', 'task:read'])]
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
private ?Workflow $workflow = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Groups(['project:read', 'project:write', 'task:read'])] #[Groups(['project:read', 'project:write', 'task:read'])]
private ?string $giteaOwner = null; private ?string $giteaOwner = null;
@@ -228,6 +250,18 @@ class Project
return $this; return $this;
} }
public function getWorkflow(): ?Workflow
{
return $this->workflow;
}
public function setWorkflow(Workflow $workflow): static
{
$this->workflow = $workflow;
return $this;
}
#[Groups(['project:read'])] #[Groups(['project:read'])]
public function getTaskCount(): int public function getTaskCount(): int
{ {

View File

@@ -478,4 +478,26 @@ class Task
; ;
} }
} }
#[Assert\Callback]
public function validateStatusBelongsToProjectWorkflow(ExecutionContextInterface $context): void
{
if (null === $this->status || null === $this->project) {
return;
}
$projectWorkflow = $this->project->getWorkflow();
$statusWorkflow = $this->status->getWorkflow();
if (null === $projectWorkflow || null === $statusWorkflow) {
return;
}
if ($projectWorkflow->getId() !== $statusWorkflow->getId()) {
$context->buildViolation('Status does not belong to this project\'s workflow.')
->atPath('status')
->addViolation()
;
}
}
} }

View File

@@ -10,9 +10,11 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Enum\StatusCategory;
use App\Repository\TaskStatusRepository; use App\Repository\TaskStatusRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource( #[ApiResource(
operations: [ operations: [
@@ -32,25 +34,36 @@ class TaskStatus
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['task_status:read', 'task:read'])] #[Groups(['task_status:read', 'task:read', 'workflow:read', 'project:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])] #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?string $label = null; private ?string $label = null;
#[ORM\Column(length: 7)] #[ORM\Column(length: 7)]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])] #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?string $color = '#222783'; private ?string $color = '#222783';
#[ORM\Column] #[ORM\Column]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])] #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private ?int $position = 0; private ?int $position = 0;
#[ORM\Column(type: 'boolean')] #[ORM\Column(type: 'boolean')]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])] #[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
private bool $isFinal = false; private bool $isFinal = false;
#[ORM\ManyToOne(targetEntity: Workflow::class, inversedBy: 'statuses')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_status:read', 'task_status:write', 'task:read'])]
#[Assert\NotNull]
private ?Workflow $workflow = null;
#[ORM\Column(type: 'string', length: 32, enumType: StatusCategory::class)]
#[Groups(['task_status:read', 'task_status:write', 'task:read', 'workflow:read', 'project:read'])]
#[Assert\NotNull]
private ?StatusCategory $category = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -103,4 +116,28 @@ class TaskStatus
return $this; return $this;
} }
public function getWorkflow(): ?Workflow
{
return $this->workflow;
}
public function setWorkflow(?Workflow $workflow): static
{
$this->workflow = $workflow;
return $this;
}
public function getCategory(): ?StatusCategory
{
return $this->category;
}
public function setCategory(StatusCategory $category): static
{
$this->category = $category;
return $this;
}
} }

View File

@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?DateTimeImmutable $createdAt = null; private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(length: 64, unique: true, nullable: true)] #[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
private ?string $apiToken = null; private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]

131
src/Entity/Workflow.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\WorkflowRepository;
use App\State\WorkflowDeleteProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: WorkflowDeleteProcessor::class),
],
normalizationContext: ['groups' => ['workflow:read']],
denormalizationContext: ['groups' => ['workflow:write']],
order: ['position' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: WorkflowRepository::class)]
#[UniqueEntity(fields: ['name'], message: 'Ce nom de workflow est déjà utilisé.')]
class Workflow
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['workflow:read', 'project:read', 'task_status:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
#[Groups(['workflow:read', 'workflow:write', 'project:read'])]
#[Assert\NotBlank]
private ?string $name = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['workflow:read', 'workflow:write'])]
private bool $isDefault = false;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
#[Groups(['workflow:read', 'workflow:write'])]
private int $position = 0;
/** @var Collection<int, TaskStatus> */
#[ORM\OneToMany(targetEntity: TaskStatus::class, mappedBy: 'workflow', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
#[Groups(['workflow:read', 'project:read'])]
private Collection $statuses;
public function __construct()
{
$this->statuses = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function isDefault(): bool
{
return $this->isDefault;
}
public function setIsDefault(bool $isDefault): static
{
$this->isDefault = $isDefault;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
/** @return Collection<int, TaskStatus> */
public function getStatuses(): Collection
{
return $this->statuses;
}
public function addStatus(TaskStatus $status): static
{
if (!$this->statuses->contains($status)) {
$this->statuses->add($status);
$status->setWorkflow($this);
}
return $this;
}
public function removeStatus(TaskStatus $status): static
{
$this->statuses->removeElement($status);
return $this;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum StatusCategory: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Blocked = 'blocked';
case Review = 'review';
case Done = 'done';
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)]
final class UniqueDefaultWorkflowListener
{
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$candidates = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Workflow && $entity->isDefault()) {
$candidates[] = $entity;
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Workflow && $entity->isDefault()) {
$candidates[] = $entity;
}
}
if (0 === count($candidates)) {
return;
}
$metadata = $em->getClassMetadata(Workflow::class);
$repo = $em->getRepository(Workflow::class);
foreach ($repo->findBy(['isDefault' => true]) as $existing) {
if (in_array($existing, $candidates, true)) {
continue;
}
$existing->setIsDefault(false);
$uow->recomputeSingleEntityChangeSet($metadata, $existing);
}
}
}

View File

@@ -24,7 +24,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')] #[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs. The status parameter must reference a status that belongs to the target project\'s workflow — otherwise the call is rejected with a validation error.')]
class CreateTaskTool class CreateTaskTool
{ {
public function __construct( public function __construct(

View File

@@ -22,7 +22,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')] #[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs. The status parameter must reference a status that belongs to the task\'s project workflow — otherwise the call is rejected with a validation error.')]
class UpdateTaskTool class UpdateTaskTool
{ {
public function __construct( public function __construct(

View File

@@ -4,33 +4,49 @@ declare(strict_types=1);
namespace App\Mcp\Tool\TaskMeta; namespace App\Mcp\Tool\TaskMeta;
use App\Entity\Project;
use App\Repository\TaskStatusRepository; use App\Repository\TaskStatusRepository;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')] #[McpTool(
name: 'list-statuses',
description: 'List task statuses. With projectId, returns only the statuses of that project\'s workflow. Without projectId, returns ALL statuses across workflows (use list-workflows to see how they group).',
)]
class ListStatusesTool class ListStatusesTool
{ {
public function __construct( public function __construct(
private readonly TaskStatusRepository $taskStatusRepository, private readonly TaskStatusRepository $taskStatusRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security, private readonly Security $security,
) {} ) {}
public function __invoke(): string public function __invoke(?int $projectId = null): string
{ {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
} }
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']); if (null !== $projectId) {
$project = $this->entityManager->find(Project::class, $projectId);
if (!$project) {
return json_encode(['error' => 'Project not found.']);
}
$statuses = $project->getWorkflow()->getStatuses()->toArray();
} else {
$statuses = $this->taskStatusRepository->findBy([], ['position' => 'ASC']);
}
return json_encode(array_map(fn ($s) => [ return json_encode(array_map(fn ($s) => [
'id' => $s->getId(), 'id' => $s->getId(),
'label' => $s->getLabel(), 'label' => $s->getLabel(),
'color' => $s->getColor(), 'color' => $s->getColor(),
'position' => $s->getPosition(), 'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(), 'isFinal' => $s->getIsFinal(),
'category' => $s->getCategory()->value,
'workflowId' => $s->getWorkflow()?->getId(),
], $statuses)); ], $statuses));
} }
} }

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use App\Repository\WorkflowRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
#[McpTool(
name: 'list-workflows',
description: 'List all workflows (status templates) with their statuses grouped under each workflow. Each project has one workflow that defines its kanban columns.',
)]
class ListWorkflowsTool
{
public function __construct(
private readonly WorkflowRepository $workflowRepository,
private readonly Security $security,
) {}
public function __invoke(): string
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$workflows = $this->workflowRepository->findBy([], ['position' => 'ASC']);
return json_encode(array_map(fn ($w) => [
'id' => $w->getId(),
'name' => $w->getName(),
'isDefault' => $w->isDefault(),
'position' => $w->getPosition(),
'statuses' => array_map(fn ($s) => [
'id' => $s->getId(),
'label' => $s->getLabel(),
'color' => $s->getColor(),
'position' => $s->getPosition(),
'isFinal' => $s->getIsFinal(),
'category' => $s->getCategory()->value,
], $w->getStatuses()->toArray()),
], $workflows));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Workflow;
use ApiPlatform\Metadata\Post;
use App\Entity\Project;
use App\State\SwitchProjectWorkflowProcessor;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Throwable;
#[McpTool(
name: 'switch-project-workflow',
description: 'Switch a project to another workflow. mapping must cover every status currently used by the project\'s tasks: keys are source status IDs (string), values are target status IDs in the new workflow (int) or null to send tasks to backlog. Requires ROLE_ADMIN. Returns { migratedTaskCount }.',
)]
class SwitchProjectWorkflowTool
{
public function __construct(
private readonly SwitchProjectWorkflowProcessor $processor,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
/**
* @param array<string, null|int> $mapping
*/
public function __invoke(int $projectId, int $workflowId, array $mapping): string
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$project = $this->entityManager->find(Project::class, $projectId);
if (!$project) {
return json_encode(['error' => 'Project not found.']);
}
$fakeRequest = Request::create('', 'POST', [], [], [], [], json_encode([
'workflowId' => $workflowId,
'mapping' => $mapping,
]));
try {
$result = $this->processor->process(
$project,
operation: new Post(name: 'switch_workflow'),
uriVariables: ['id' => $projectId],
context: ['request' => $fakeRequest],
);
} catch (Throwable $e) {
return json_encode(['error' => $e->getMessage()]);
}
return json_encode([
'migratedTaskCount' => $result->migratedTaskCount,
'projectId' => $result->projectId,
'workflowId' => $result->workflowId,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Workflow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Workflow>
*/
class WorkflowRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Workflow::class);
}
public function findDefault(): ?Workflow
{
return $this->findOneBy(['isDefault' => true]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\SwitchWorkflowOutput;
use App\Entity\Project;
use App\Entity\TaskStatus;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
/**
* Wraps the switch-workflow operation for a project.
* Input: Project (URI variable) + body { workflowId, mapping: { sourceStatusId: targetStatusId|null } }.
*
* @implements ProcessorInterface<Project, SwitchWorkflowOutput>
*/
final readonly class SwitchProjectWorkflowProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): SwitchWorkflowOutput
{
/** @var Project $project */
$project = $data;
$request = $context['request'] ?? null;
$body = $request ? json_decode($request->getContent(), true) : [];
$workflowId = $body['workflowId'] ?? null;
$mapping = $body['mapping'] ?? [];
if (!is_int($workflowId) || !is_array($mapping)) {
throw new HttpException(422, 'Body must contain workflowId (int) and mapping (object).');
}
$targetWorkflow = $this->entityManager->find(Workflow::class, $workflowId);
if (!$targetWorkflow instanceof Workflow) {
throw new NotFoundHttpException('Target workflow not found.');
}
// 1) Lister les statuts source effectivement référencés par les tâches du projet
$rows = $this->entityManager->getConnection()->fetchAllAssociative(
'SELECT DISTINCT status_id FROM task WHERE project_id = :pid AND status_id IS NOT NULL',
['pid' => $project->getId()],
);
$referencedSourceIds = array_map(static fn ($r) => (int) $r['status_id'], $rows);
// 2) Vérifier que chaque source a un mapping
$missing = [];
foreach ($referencedSourceIds as $srcId) {
if (!array_key_exists((string) $srcId, $mapping)) {
$missing[] = $srcId;
}
}
if ([] !== $missing) {
throw new HttpException(422, 'Missing mapping for source status IDs: '.implode(', ', $missing));
}
// 3) Valider que chaque target appartient au workflow cible (ou est null)
foreach ($mapping as $srcId => $targetId) {
if (null === $targetId) {
continue;
}
$target = $this->entityManager->find(TaskStatus::class, $targetId);
if (!$target instanceof TaskStatus
|| $target->getWorkflow()?->getId() !== $targetWorkflow->getId()) {
throw new HttpException(422, sprintf(
'Target status %s does not belong to workflow %d.',
var_export($targetId, true),
$targetWorkflow->getId(),
));
}
}
// 4) Transaction unique
$conn = $this->entityManager->getConnection();
$conn->beginTransaction();
try {
$migrated = 0;
foreach ($mapping as $srcId => $targetId) {
$affected = $conn->executeStatement(
'UPDATE task SET status_id = :tid WHERE project_id = :pid AND status_id = :sid',
['tid' => $targetId, 'pid' => $project->getId(), 'sid' => (int) $srcId],
);
$migrated += $affected;
}
$project->setWorkflow($targetWorkflow);
$this->entityManager->flush();
$conn->commit();
} catch (Throwable $e) {
$conn->rollBack();
throw $e;
}
return new SwitchWorkflowOutput(
projectId: $project->getId(),
workflowId: $targetWorkflow->getId(),
migratedTaskCount: $migrated,
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Workflow;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* @implements ProcessorInterface<Workflow, void>
*/
final readonly class WorkflowDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
/** @var Workflow $workflow */
$workflow = $data;
$count = (int) $this->entityManager->getConnection()->fetchOne(
'SELECT COUNT(*) FROM project WHERE workflow_id = :id',
['id' => $workflow->getId()],
);
if ($count > 0) {
throw new HttpException(409, sprintf(
'Workflow used by %d project(s). Reassign them before deleting.',
$count,
));
}
$this->entityManager->remove($workflow);
$this->entityManager->flush();
}
}