Merge branch 'develop' into feat/mail-integration
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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 ###
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.34'
|
app.version: '0.4.0'
|
||||||
|
|||||||
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal 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.
|
||||||
@@ -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>
|
|
||||||
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
100
frontend/components/admin/AdminWorkflowTab.vue
Normal 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>
|
||||||
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
261
frontend/components/admin/WorkflowDrawer.vue
Normal 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>
|
||||||
@@ -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[]>([])
|
||||||
|
|||||||
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:help-circle-outline"
|
||||||
|
aria-label="Centre d'aide"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
|
@click="navigateTo('/help')"
|
||||||
|
/>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
27
frontend/content/help/01-getting-started.md
Normal file
27
frontend/content/help/01-getting-started.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Bienvenue dans Lesstime
|
||||||
|
|
||||||
|
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||||
|
|
||||||
|
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||||
|
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||||
|
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||||
|
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||||
|
|
||||||
|
## Comprendre les rôles
|
||||||
|
|
||||||
|
| Rôle | Accès |
|
||||||
|
|---|---|
|
||||||
|
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||||
|
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||||
|
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||||
|
|
||||||
|
## Vues principales
|
||||||
|
|
||||||
|
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||||
|
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||||
|
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||||
|
- **Time tracking** : timer, time entries, vue mois
|
||||||
|
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||||
|
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||||
|
|
||||||
|
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||||
58
frontend/content/help/02-projects-workflows.md
Normal file
58
frontend/content/help/02-projects-workflows.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Projets & Workflows
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un projet ?
|
||||||
|
|
||||||
|
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||||
|
|
||||||
|
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||||
|
- Un **client** optionnel (ou interne si null)
|
||||||
|
- Une **couleur** d'identification
|
||||||
|
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un workflow ?
|
||||||
|
|
||||||
|
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||||
|
|
||||||
|
### Exemple
|
||||||
|
|
||||||
|
| Workflow | Statuts |
|
||||||
|
|---|---|
|
||||||
|
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||||
|
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||||
|
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||||
|
|
||||||
|
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||||
|
|
||||||
|
## Les 5 catégories canoniques
|
||||||
|
|
||||||
|
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||||
|
|
||||||
|
| Catégorie | Description |
|
||||||
|
|---|---|
|
||||||
|
| `todo` | À faire — pas encore commencé |
|
||||||
|
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||||
|
| `blocked` | Bloqué — attente d'une dépendance |
|
||||||
|
| `review` | En validation — relecture, PR, QA |
|
||||||
|
| `done` | Terminé — close |
|
||||||
|
|
||||||
|
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||||
|
|
||||||
|
## Changer le workflow d'un projet
|
||||||
|
|
||||||
|
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||||
|
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||||
|
3. Sélectionner le workflow cible
|
||||||
|
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||||
|
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||||
|
|
||||||
|
### Règles du mapping
|
||||||
|
|
||||||
|
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||||
|
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||||
|
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||||
|
|
||||||
|
## Supprimer un workflow
|
||||||
|
|
||||||
|
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||||
|
|
||||||
|
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||||
60
frontend/content/help/03-my-tasks.md
Normal file
60
frontend/content/help/03-my-tasks.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Mes tâches & Dashboard
|
||||||
|
|
||||||
|
## Vue *Mes tâches*
|
||||||
|
|
||||||
|
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||||
|
|
||||||
|
### Deux modes d'affichage
|
||||||
|
|
||||||
|
#### 1. Kanban (par défaut)
|
||||||
|
|
||||||
|
Regroupé par les **5 catégories canoniques** :
|
||||||
|
|
||||||
|
```
|
||||||
|
À faire → En cours → Bloqué → En validation → Terminé
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque card affiche :
|
||||||
|
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||||
|
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||||
|
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||||
|
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||||
|
|
||||||
|
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||||
|
|
||||||
|
#### 2. Liste
|
||||||
|
|
||||||
|
Vue tableau triable, avec **bulk actions** :
|
||||||
|
- Cocher plusieurs tâches → barre d'actions en haut
|
||||||
|
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||||
|
- Supprimer en lot
|
||||||
|
|
||||||
|
### Filtres disponibles
|
||||||
|
|
||||||
|
| Filtre | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Projet** | Restreint à un projet précis |
|
||||||
|
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||||
|
| **Tag** | Tags globaux |
|
||||||
|
| **Priorité / Effort** | |
|
||||||
|
| **Assigné** | Par défaut : toi-même |
|
||||||
|
|
||||||
|
### Tri (vue liste uniquement)
|
||||||
|
|
||||||
|
- Par **deadline** (les plus proches en premier)
|
||||||
|
- Par **scheduled start** (planification calendrier)
|
||||||
|
|
||||||
|
## Vue *Backlog*
|
||||||
|
|
||||||
|
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Le **dashboard** (page d'accueil après login) affiche :
|
||||||
|
|
||||||
|
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||||
|
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||||
|
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||||
|
- ⏱ **Timer actif** s'il y en a un
|
||||||
|
|
||||||
|
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||||
59
frontend/content/help/04-time-tracking.md
Normal file
59
frontend/content/help/04-time-tracking.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Time tracking
|
||||||
|
|
||||||
|
## Le timer
|
||||||
|
|
||||||
|
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||||
|
|
||||||
|
### Démarrer un timer
|
||||||
|
|
||||||
|
Trois façons :
|
||||||
|
|
||||||
|
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||||
|
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||||
|
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||||
|
|
||||||
|
### Arrêter
|
||||||
|
|
||||||
|
- Clique sur ⏹ sur la card de la tâche en cours
|
||||||
|
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||||
|
|
||||||
|
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||||
|
|
||||||
|
## Time entries
|
||||||
|
|
||||||
|
Chaque entrée a :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||||
|
| **Projet** | Obligatoire |
|
||||||
|
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||||
|
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||||
|
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||||
|
| **User** | Qui a fait le travail |
|
||||||
|
|
||||||
|
### Vue *Time tracking*
|
||||||
|
|
||||||
|
Disponible en deux modes :
|
||||||
|
|
||||||
|
- **Vue semaine** : ligne par ligne, par jour
|
||||||
|
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
|
||||||
|
- **Projet** (server-side)
|
||||||
|
- **Tag** (server-side)
|
||||||
|
- **User** (admin uniquement)
|
||||||
|
- **Période** (date début / date fin)
|
||||||
|
|
||||||
|
## Édition
|
||||||
|
|
||||||
|
- Clique sur une time entry → drawer d'édition
|
||||||
|
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||||
|
- La suppression est libre — pense à exporter avant si nécessaire
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||||
|
|
||||||
|
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||||
62
frontend/content/help/05-tasks-detail.md
Normal file
62
frontend/content/help/05-tasks-detail.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Détail d'une tâche
|
||||||
|
|
||||||
|
## Champs principaux
|
||||||
|
|
||||||
|
| Champ | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||||
|
| **Titre** | Obligatoire |
|
||||||
|
| **Description** | Markdown supporté (preview disponible) |
|
||||||
|
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||||
|
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||||
|
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||||
|
| **Assigné** | Un seul user responsable |
|
||||||
|
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||||
|
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||||
|
| **Tags** | Globaux, plusieurs par tâche |
|
||||||
|
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||||
|
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||||
|
|
||||||
|
## Récurrence
|
||||||
|
|
||||||
|
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||||
|
|
||||||
|
- **Type** : quotidien, hebdomadaire, mensuel
|
||||||
|
- **Intervalle** : tous les N jours/semaines/mois
|
||||||
|
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||||
|
|
||||||
|
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||||
|
|
||||||
|
## Sync calendrier
|
||||||
|
|
||||||
|
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||||
|
|
||||||
|
Icônes correspondantes :
|
||||||
|
- 🟢 `mdi:calendar-check` → sync OK
|
||||||
|
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||||
|
|
||||||
|
- Drag & drop dans la tâche pour uploader
|
||||||
|
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||||
|
- Téléchargement via lien dédié
|
||||||
|
|
||||||
|
## Liaison Gitea (si configuré)
|
||||||
|
|
||||||
|
Si le projet a un repo Gitea lié, tu peux :
|
||||||
|
|
||||||
|
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||||
|
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||||
|
- **Voir les PRs** liées (état CI inclus)
|
||||||
|
|
||||||
|
## Liaison ticket client
|
||||||
|
|
||||||
|
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Commentaires & notifications
|
||||||
|
|
||||||
|
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||||
|
- Les @mentions notifient l'utilisateur cité
|
||||||
|
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||||
43
frontend/content/help/06-client-portal.md
Normal file
43
frontend/content/help/06-client-portal.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Portal client
|
||||||
|
|
||||||
|
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||||
|
|
||||||
|
## Ce que voit un client
|
||||||
|
|
||||||
|
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||||
|
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||||
|
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||||
|
|
||||||
|
## Soumettre un ticket
|
||||||
|
|
||||||
|
Depuis `/portal/projects/<id>/new-ticket` :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Type** | `bug` / `improvement` / `other` |
|
||||||
|
| **Titre** | Court et descriptif |
|
||||||
|
| **Description** | Détails — markdown supporté |
|
||||||
|
| **URL** | Optionnel — page où le problème se manifeste |
|
||||||
|
|
||||||
|
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Statuts d'un ticket
|
||||||
|
|
||||||
|
| Statut | Visible côté client | Signification |
|
||||||
|
|---|---|---|
|
||||||
|
| `new` | Oui | Reçu, pas encore traité |
|
||||||
|
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||||
|
| `done` | Oui | Résolu et clôturé |
|
||||||
|
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||||
|
|
||||||
|
Le `statusComment` est visible par le client quand fourni.
|
||||||
|
|
||||||
|
## Côté équipe interne
|
||||||
|
|
||||||
|
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||||
|
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||||
|
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||||
66
frontend/content/help/07-admin.md
Normal file
66
frontend/content/help/07-admin.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Administration
|
||||||
|
|
||||||
|
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||||
|
|
||||||
|
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||||
|
|
||||||
|
## Onglet *Clients*
|
||||||
|
|
||||||
|
- Liste des clients (entreprise / organisation)
|
||||||
|
- Champs : nom, email, téléphone, adresse
|
||||||
|
- Lier un client à des projets
|
||||||
|
|
||||||
|
## Onglet *Workflows*
|
||||||
|
|
||||||
|
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||||
|
|
||||||
|
- Lister les workflows existants
|
||||||
|
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||||
|
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||||
|
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||||
|
|
||||||
|
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||||
|
|
||||||
|
## Onglet *Efforts*
|
||||||
|
|
||||||
|
- Tailles d'effort (S, M, L, XL, XXL)
|
||||||
|
- Globales (partagées entre tous les projets)
|
||||||
|
|
||||||
|
## Onglet *Priorités*
|
||||||
|
|
||||||
|
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||||
|
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||||
|
|
||||||
|
## Onglet *Tags*
|
||||||
|
|
||||||
|
- Tags globaux (tâches **et** time entries)
|
||||||
|
- Couleur personnalisable
|
||||||
|
- Pas de hiérarchie (flat list)
|
||||||
|
|
||||||
|
## Onglet *Utilisateurs*
|
||||||
|
|
||||||
|
- Créer / éditer / désactiver
|
||||||
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||||
|
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||||
|
- Reset password depuis l'admin
|
||||||
|
|
||||||
|
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||||
|
|
||||||
|
## Onglet *Gitea*
|
||||||
|
|
||||||
|
- URL serveur + token API
|
||||||
|
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||||
|
- Active les fonctionnalités branches / PRs sur les tâches
|
||||||
|
|
||||||
|
## Onglet *BookStack*
|
||||||
|
|
||||||
|
- URL + token API
|
||||||
|
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||||
|
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||||
|
|
||||||
|
## Onglet *Zimbra*
|
||||||
|
|
||||||
|
- URL serveur + credentials (chiffrés via libsodium)
|
||||||
|
- Configure le calendrier CalDav par défaut
|
||||||
|
- Test de connexion intégré
|
||||||
|
- Active la **sync calendrier** sur les tâches planifiées
|
||||||
66
frontend/content/help/08-integrations.md
Normal file
66
frontend/content/help/08-integrations.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Intégrations
|
||||||
|
|
||||||
|
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||||
|
|
||||||
|
## 🌳 Gitea
|
||||||
|
|
||||||
|
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Gitea** : URL serveur + token API
|
||||||
|
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche, le panneau Gitea propose :
|
||||||
|
|
||||||
|
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||||
|
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||||
|
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||||
|
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||||
|
|
||||||
|
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||||
|
|
||||||
|
## 📚 BookStack
|
||||||
|
|
||||||
|
Lien tâche → documentation.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||||
|
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||||
|
- Sélectionner la page dans le shelf du projet
|
||||||
|
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||||
|
|
||||||
|
## 📅 Zimbra (CalDav)
|
||||||
|
|
||||||
|
Sync calendrier pour les tâches planifiées.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Zimbra** :
|
||||||
|
- URL serveur (ex: `https://mail.ovh.com`)
|
||||||
|
- Username (ex: `lesstime@ovh.fr`)
|
||||||
|
- Password (chiffré côté serveur)
|
||||||
|
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||||
|
- **Test de connexion** intégré
|
||||||
|
2. Active la config (toggle `enabled`)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche avec **scheduled start + end** :
|
||||||
|
|
||||||
|
1. Cocher **Sync calendrier**
|
||||||
|
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||||
|
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||||
|
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||||
|
|
||||||
|
### Limites
|
||||||
|
|
||||||
|
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||||
|
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||||
97
frontend/content/help/09-mcp-api.md
Normal file
97
frontend/content/help/09-mcp-api.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Token MCP & API
|
||||||
|
|
||||||
|
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||||
|
|
||||||
|
## Générer ton token
|
||||||
|
|
||||||
|
1. Va sur **Profil** (avatar → Profil)
|
||||||
|
2. Section **Token MCP** → **Générer un token**
|
||||||
|
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||||
|
|
||||||
|
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||||
|
|
||||||
|
## Configurer Claude Code
|
||||||
|
|
||||||
|
Dans `.mcp.json` (à la racine de ton projet) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://ton-instance-lesstime/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour une instance locale :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime-local": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools disponibles (27 au total)
|
||||||
|
|
||||||
|
### Projets
|
||||||
|
|
||||||
|
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||||
|
|
||||||
|
### Tâches
|
||||||
|
|
||||||
|
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||||
|
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||||
|
|
||||||
|
### Métadonnées
|
||||||
|
|
||||||
|
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||||
|
- `list-priorities`, `list-efforts`, `list-tags`
|
||||||
|
|
||||||
|
### Workflows ⭐ Nouveau
|
||||||
|
|
||||||
|
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||||
|
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||||
|
|
||||||
|
### Time tracking
|
||||||
|
|
||||||
|
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||||
|
|
||||||
|
### Récurrence
|
||||||
|
|
||||||
|
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||||
|
|
||||||
|
### Groupes / Users / Clients
|
||||||
|
|
||||||
|
- `list-groups`, `create-group`, `update-group`
|
||||||
|
- `list-users`, `list-clients`
|
||||||
|
|
||||||
|
## Règles importantes
|
||||||
|
|
||||||
|
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||||
|
|
||||||
|
## Exemples de prompts
|
||||||
|
|
||||||
|
```
|
||||||
|
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||||
|
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Liste mes tâches en cours dans le projet CRM"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
L'agent appelle les bons tools tout seul si la description est claire.
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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'" />
|
||||||
@@ -41,7 +41,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' },
|
||||||
|
|||||||
168
frontend/pages/help.vue
Normal file
168
frontend/pages/help.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
useHead({ title: 'Aide' })
|
||||||
|
|
||||||
|
type Section = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
accent: string
|
||||||
|
roles: ('admin' | 'user' | 'client')[]
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||||
|
|
||||||
|
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||||
|
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||||
|
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||||
|
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||||
|
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||||
|
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||||
|
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||||
|
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||||
|
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||||
|
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = computed<Section[]>(() => {
|
||||||
|
return Object.entries(rawModules).map(([path, raw]) => {
|
||||||
|
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||||
|
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||||
|
return { id, ...meta, content: raw }
|
||||||
|
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||||
|
const roles = auth.user?.roles ?? []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||||
|
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||||
|
return 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleSections = computed(() =>
|
||||||
|
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||||
|
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||||
|
activeId.value = hash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeId, (id) => {
|
||||||
|
router.replace({ query: { ...route.query, section: id } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!activeSection.value) return ''
|
||||||
|
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
|
||||||
|
const prevSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||||
|
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||||
|
<Icon name="mdi:lifebuoy" size="20" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||||
|
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||||
|
<button
|
||||||
|
v-for="section in visibleSections"
|
||||||
|
:key="section.id"
|
||||||
|
type="button"
|
||||||
|
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||||
|
:class="activeId === section.id
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||||
|
@click="activeId = section.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||||
|
:class="section.accent"
|
||||||
|
>
|
||||||
|
<Icon :name="section.icon" size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||||
|
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||||
|
<!-- Hero header -->
|
||||||
|
<div
|
||||||
|
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||||
|
:class="activeSection.accent"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||||
|
<Icon :name="activeSection.icon" size="28" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown content -->
|
||||||
|
<article
|
||||||
|
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!prevSection"
|
||||||
|
@click="prevSection && (activeId = prevSection.id)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-left" size="18" />
|
||||||
|
<span>{{ prevSection?.title ?? '' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!nextSection"
|
||||||
|
@click="nextSection && (activeId = nextSection.id)"
|
||||||
|
>
|
||||||
|
<span>{{ nextSection?.title ?? '' }}</span>
|
||||||
|
<Icon name="mdi:arrow-right" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
27
frontend/services/dto/workflow.ts
Normal file
27
frontend/services/dto/workflow.ts
Normal 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[]
|
||||||
|
}
|
||||||
55
frontend/services/workflows.ts
Normal file
55
frontend/services/workflows.ts
Normal 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 }
|
||||||
|
}
|
||||||
36
migrations/Version20260519175041.php
Normal file
36
migrations/Version20260519175041.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
migrations/Version20260519175114.php
Normal file
74
migrations/Version20260519175114.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
migrations/Version20260519175142.php
Normal file
38
migrations/Version20260519175142.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
migrations/Version20260519175338.php
Normal file
38
migrations/Version20260519175338.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/ApiResource/SwitchWorkflowOutput.php
Normal file
26
src/ApiResource/SwitchWorkflowOutput.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,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;
|
||||||
@@ -87,57 +89,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;
|
||||||
}
|
}
|
||||||
@@ -148,6 +124,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');
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/Entity/Workflow.php
Normal file
131
src/Entity/Workflow.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Enum/StatusCategory.php
Normal file
14
src/Enum/StatusCategory.php
Normal 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';
|
||||||
|
}
|
||||||
46
src/EventListener/UniqueDefaultWorkflowListener.php
Normal file
46
src/EventListener/UniqueDefaultWorkflowListener.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/Mcp/Tool/Workflow/ListWorkflowsTool.php
Normal file
46
src/Mcp/Tool/Workflow/ListWorkflowsTool.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php
Normal file
65
src/Mcp/Tool/Workflow/SwitchProjectWorkflowTool.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Repository/WorkflowRepository.php
Normal file
25
src/Repository/WorkflowRepository.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/State/SwitchProjectWorkflowProcessor.php
Normal file
113
src/State/SwitchProjectWorkflowProcessor.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/State/WorkflowDeleteProcessor.php
Normal file
42
src/State/WorkflowDeleteProcessor.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user