Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bebfe1595 | |||
| 49267ad2fb | |||
| d3abb584a9 | |||
| 98e3990fa5 | |||
| 172f79d348 |
@@ -125,6 +125,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
||||||
|
|
||||||
|
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
|
||||||
|
tags:
|
||||||
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.44'
|
app.version: '0.4.45'
|
||||||
|
|||||||
@@ -11,33 +11,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
v-model="showArchived"
|
|
||||||
:label="$t('users.showArchived')"
|
|
||||||
:reserve-message-space="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
empty-message="Aucun utilisateur trouvé."
|
empty-message="Aucun utilisateur trouvé."
|
||||||
|
deletable
|
||||||
@row-click="openEdit"
|
@row-click="openEdit"
|
||||||
|
@delete="(item) => handleDelete(item.id)"
|
||||||
>
|
>
|
||||||
<template #cell-username="{ item }">
|
|
||||||
<span :class="{ 'text-neutral-400 line-through': item.archived }">
|
|
||||||
{{ item.username }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="item.archived"
|
|
||||||
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
|
|
||||||
>
|
|
||||||
{{ $t('users.archivedBadge') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #cell-roles="{ item }">
|
<template #cell-roles="{ item }">
|
||||||
<span
|
<span
|
||||||
v-for="role in item.roles"
|
v-for="role in item.roles"
|
||||||
@@ -47,27 +29,6 @@
|
|||||||
{{ role }}
|
{{ role }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions="{ item }">
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-if="item.archived"
|
|
||||||
icon="mdi:restore"
|
|
||||||
:aria-label="$t('users.restore')"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="20"
|
|
||||||
button-class="text-neutral-400 hover:text-primary-500"
|
|
||||||
@click.stop="handleRestore(item)"
|
|
||||||
/>
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-else-if="item.id !== currentUserId"
|
|
||||||
icon="mdi:delete-outline"
|
|
||||||
:aria-label="$t('users.archive')"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="20"
|
|
||||||
button-class="text-neutral-400 hover:text-red-500"
|
|
||||||
@click.stop="openArchiveConfirm(item)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
@@ -75,19 +36,12 @@
|
|||||||
:item="selectedItem"
|
:item="selectedItem"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmArchiveUserModal
|
|
||||||
v-model="archiveConfirmOpen"
|
|
||||||
:username="userToArchive?.username ?? ''"
|
|
||||||
@confirm="confirmArchive"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
import { useAuthStore } from '~/shared/stores/auth'
|
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
@@ -96,27 +50,16 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'roles', label: 'Rôles' },
|
{ key: 'roles', label: 'Rôles' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const { getAll, getArchived, remove, restore } = useUserService()
|
const { getAll, remove } = useUserService()
|
||||||
const authStore = useAuthStore()
|
|
||||||
const currentUserId = computed(() => authStore.user?.id)
|
|
||||||
|
|
||||||
const items = ref<UserData[]>([])
|
const items = ref<UserData[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedItem = ref<UserData | null>(null)
|
const selectedItem = ref<UserData | null>(null)
|
||||||
const showArchived = ref(false)
|
|
||||||
const archiveConfirmOpen = ref(false)
|
|
||||||
const userToArchive = ref<UserData | null>(null)
|
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (showArchived.value) {
|
items.value = await getAll()
|
||||||
const [active, archived] = await Promise.all([getAll(), getArchived()])
|
|
||||||
items.value = [...active, ...archived]
|
|
||||||
} else {
|
|
||||||
items.value = await getAll()
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -132,23 +75,8 @@ function openEdit(item: UserData) {
|
|||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openArchiveConfirm(item: UserData) {
|
async function handleDelete(id: number) {
|
||||||
userToArchive.value = item
|
await remove(id)
|
||||||
archiveConfirmOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmArchive() {
|
|
||||||
if (!userToArchive.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await remove(userToArchive.value.id)
|
|
||||||
archiveConfirmOpen.value = false
|
|
||||||
userToArchive.value = null
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRestore(item: UserData) {
|
|
||||||
await restore(item.id)
|
|
||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +84,6 @@ async function onSaved() {
|
|||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(showArchived, () => {
|
|
||||||
loadItems()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadItems()
|
loadItems()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,57 +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('users.archiveConfirmTitle') }}</h3>
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ $t('users.archiveConfirmMessage', { username }) }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
label="Annuler"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="cancel"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
:label="$t('users.archive')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="$emit('confirm')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
username: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -194,16 +194,8 @@
|
|||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès.",
|
"deleted": "Utilisateur supprimé avec succès.",
|
||||||
"archived": "Utilisateur archivé avec succès.",
|
|
||||||
"restored": "Utilisateur restauré avec succès.",
|
|
||||||
"addUser": "Ajouter un utilisateur",
|
"addUser": "Ajouter un utilisateur",
|
||||||
"editUser": "Modifier un utilisateur",
|
"editUser": "Modifier un utilisateur"
|
||||||
"archivedBadge": "Archivé",
|
|
||||||
"showArchived": "Afficher les utilisateurs archivés",
|
|
||||||
"archive": "Archiver",
|
|
||||||
"restore": "Restaurer",
|
|
||||||
"archiveConfirmTitle": "Archiver l'utilisateur",
|
|
||||||
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
|
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
empty-option-label="Aucun client"
|
empty-option-label="Aucun client"
|
||||||
group-class="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!isEditing"
|
||||||
|
v-model="form.workflowId"
|
||||||
|
:options="workflowOptions"
|
||||||
|
label="Workflow"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
</div>
|
</div>
|
||||||
@@ -124,10 +131,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||||
|
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||||
import type { Client } from '~/modules/directory/services/dto/client'
|
import type { Client } from '~/modules/directory/services/dto/client'
|
||||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||||
|
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||||
|
|
||||||
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
|
|||||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { getAll: getAllWorkflows } = useWorkflowService()
|
||||||
|
const workflows = ref<Workflow[]>([])
|
||||||
|
|
||||||
|
const workflowOptions = computed(() =>
|
||||||
|
workflows.value.map(w => ({ label: w.name, value: w.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
function defaultWorkflowId(): number | null {
|
||||||
|
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
color: '#222783',
|
color: '#222783',
|
||||||
clientId: null as number | null,
|
clientId: null as number | null,
|
||||||
|
workflowId: null as number | null,
|
||||||
giteaRepoFullName: null as string | null,
|
giteaRepoFullName: null as string | null,
|
||||||
bookstackShelfId: null as number | null,
|
bookstackShelfId: null as number | null,
|
||||||
})
|
})
|
||||||
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
|
|||||||
form.description = ''
|
form.description = ''
|
||||||
form.color = '#222783'
|
form.color = '#222783'
|
||||||
form.clientId = null
|
form.clientId = null
|
||||||
|
form.workflowId = defaultWorkflowId()
|
||||||
form.giteaRepoFullName = null
|
form.giteaRepoFullName = null
|
||||||
form.bookstackShelfId = null
|
form.bookstackShelfId = null
|
||||||
}
|
}
|
||||||
@@ -269,6 +291,9 @@ async function handleSubmit() {
|
|||||||
await update(props.project.id, payload)
|
await update(props.project.id, payload)
|
||||||
} else {
|
} else {
|
||||||
payload.code = form.code
|
payload.code = form.code
|
||||||
|
if (form.workflowId) {
|
||||||
|
payload.workflow = `/api/workflows/${form.workflowId}`
|
||||||
|
}
|
||||||
await create(payload)
|
await create(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
workflows.value = await getAllWorkflows()
|
||||||
|
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
|
||||||
|
if (props.modelValue && !props.project && !form.workflowId) {
|
||||||
|
form.workflowId = defaultWorkflowId()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Workflows indisponibles, ignore (le serveur assignera le défaut)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
giteaRepos.value = await listRepositories()
|
giteaRepos.value = await listRepositories()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export type UserData = {
|
|||||||
effectivePermissions?: string[]
|
effectivePermissions?: string[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: string | null
|
apiToken?: string | null
|
||||||
// Soft-delete flag: an archived user keeps its data but cannot log in
|
|
||||||
archived?: boolean
|
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
|
|||||||
@@ -10,13 +10,6 @@ export function useUserService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archived users are hidden from the default collection; an admin lists
|
|
||||||
// them explicitly via the `archived` filter (handled server-side).
|
|
||||||
async function getArchived(): Promise<UserData[]> {
|
|
||||||
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
|
|
||||||
return extractHydraMembers(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getById(id: number): Promise<UserData> {
|
async function getById(id: number): Promise<UserData> {
|
||||||
return api.get<UserData>(`/users/${id}`)
|
return api.get<UserData>(`/users/${id}`)
|
||||||
}
|
}
|
||||||
@@ -33,19 +26,11 @@ export function useUserService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting a user is a soft delete server-side: the account is archived
|
|
||||||
// (kept for referential integrity) rather than removed.
|
|
||||||
async function remove(id: number): Promise<void> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/users/${id}`, {}, {
|
await api.delete(`/users/${id}`, {}, {
|
||||||
toastSuccessKey: 'users.archived',
|
toastSuccessKey: 'users.deleted',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(id: number): Promise<UserData> {
|
return { getAll, getById, create, update, remove }
|
||||||
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
|
|
||||||
toastSuccessKey: 'users.restored',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { getAll, getArchived, getById, create, update, remove, restore }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
|
|||||||
#[Groups(['project:read', 'project:write'])]
|
#[Groups(['project:read', 'project:write'])]
|
||||||
private ?ClientInterface $client = null;
|
private ?ClientInterface $client = null;
|
||||||
|
|
||||||
|
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
|
||||||
|
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
|
||||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
|
||||||
private ?Workflow $workflow = null;
|
private ?Workflow $workflow = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
|
||||||
|
|
||||||
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the default workflow to a project when none was provided.
|
||||||
|
* Guarantees the NOT NULL workflow_id constraint across every persistence
|
||||||
|
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
|
||||||
|
*/
|
||||||
|
final readonly class ProjectDefaultWorkflowListener
|
||||||
|
{
|
||||||
|
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
|
||||||
|
|
||||||
|
public function prePersist(Project $project, PrePersistEventArgs $args): void
|
||||||
|
{
|
||||||
|
if (null !== $project->getWorkflow()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = $this->workflowRepository->findDefault()
|
||||||
|
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
|
||||||
|
|
||||||
|
if (null === $default) {
|
||||||
|
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->setWorkflow($default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
|
|||||||
|
|
||||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
|
||||||
class CreateProjectTool
|
class CreateProjectTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly ClientRepositoryInterface $clientRepository,
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly WorkflowRepositoryInterface $workflowRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ class CreateProjectTool
|
|||||||
?string $description = null,
|
?string $description = null,
|
||||||
?string $color = null,
|
?string $color = null,
|
||||||
?int $clientId = null,
|
?int $clientId = null,
|
||||||
|
?int $workflowId = null,
|
||||||
): string {
|
): 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.');
|
||||||
@@ -52,6 +55,14 @@ class CreateProjectTool
|
|||||||
}
|
}
|
||||||
$project->setClient($client);
|
$project->setClient($client);
|
||||||
}
|
}
|
||||||
|
if (null !== $workflowId) {
|
||||||
|
$workflow = $this->workflowRepository->findById($workflowId);
|
||||||
|
if (null === $workflow) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
|
||||||
|
}
|
||||||
|
$project->setWorkflow($workflow);
|
||||||
|
}
|
||||||
|
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
|
||||||
|
|
||||||
$this->entityManager->persist($project);
|
$this->entityManager->persist($project);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Module\ProjectManagement;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\ProjectManagement\Domain\Entity\Workflow;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
|
||||||
|
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
|
||||||
|
* - avec workflow → le workflow choisi est conservé.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ProjectCreationWorkflowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$client->loginUser($this->createManager($em));
|
||||||
|
|
||||||
|
$client->request('POST', '/api/projects', server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
], content: json_encode([
|
||||||
|
'code' => $this->randomCode(),
|
||||||
|
'name' => 'Projet sans workflow',
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertArrayHasKey('workflow', $data);
|
||||||
|
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateProjectWithExplicitWorkflow(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
|
||||||
|
?? $em->getRepository(Workflow::class)->findOneBy([]);
|
||||||
|
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
|
||||||
|
|
||||||
|
$client->loginUser($this->createManager($em));
|
||||||
|
|
||||||
|
$client->request('POST', '/api/projects', server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
], content: json_encode([
|
||||||
|
'code' => $this->randomCode(),
|
||||||
|
'name' => 'Projet avec workflow',
|
||||||
|
'workflow' => '/api/workflows/'.$workflow->getId(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createManager(EntityManagerInterface $em): User
|
||||||
|
{
|
||||||
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
|
||||||
|
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('proj-create-'.uniqid());
|
||||||
|
$user->setPassword('x');
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
$user->addDirectPermission($permission);
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function randomCode(): string
|
||||||
|
{
|
||||||
|
$letters = '';
|
||||||
|
for ($i = 0; $i < 6; ++$i) {
|
||||||
|
$letters .= chr(random_int(65, 90));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $letters;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user