Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 267cea76da | |||
| 6938616064 | |||
| 386242c84d | |||
| 41a98f93ee | |||
| aad949c10c | |||
| ad029f5c7d | |||
| 99626b89da |
@@ -113,6 +113,14 @@ services:
|
|||||||
|
|
||||||
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
||||||
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 }
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.37'
|
app.version: '0.4.39'
|
||||||
|
|||||||
@@ -105,8 +105,7 @@
|
|||||||
|
|
||||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<AppTopNav :user="auth.user" />
|
<AppTopNav :user="auth.user" />
|
||||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-6 lg:px-12 xl:px-11">
|
||||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Entête de page standard : source unique du style des titres.
|
||||||
|
Toujours sticky en haut du <main> scrollable : reste visible au scroll.
|
||||||
|
Fond blanc + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu
|
||||||
|
défilant soit masqué sous l'entête (espaces haut ET bas compris) et que
|
||||||
|
l'entête soit collée sous l'AppTopNav sans trou.
|
||||||
|
Slots :
|
||||||
|
- défaut : texte du titre
|
||||||
|
- #actions : boutons à droite du titre
|
||||||
|
- #subheader : barre de filtres / onglets rendue SOUS le titre, dans le
|
||||||
|
même bloc sticky (reste donc collée avec le titre). La
|
||||||
|
marge titre -> sous-entête est portée par le contenu passé
|
||||||
|
(ex. mt-4) pour laisser chaque page régler son cas. -->
|
||||||
|
<div class="sticky top-0 z-20 bg-white pt-[38px] pb-[30px]">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-[30px] font-semibold text-primary-500">
|
||||||
|
<slot/>
|
||||||
|
</h1>
|
||||||
|
<div v-if="$slots.actions" class="shrink-0">
|
||||||
|
<slot name="actions"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot name="subheader"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<PageHeader>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
{{ $t('absences.title') }}
|
||||||
<MalioButton
|
<template #actions>
|
||||||
:label="$t('absences.newRequest')"
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
:label="$t('absences.newRequest')"
|
||||||
icon-position="left"
|
icon-name="mdi:plus"
|
||||||
@click="requestDrawerOpen = true"
|
icon-position="left"
|
||||||
/>
|
@click="requestDrawerOpen = true"
|
||||||
</div>
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<AbsenceBalanceCards :balances="balances" />
|
<AbsenceBalanceCards :balances="balances" />
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
:can-cancel="selected?.status === 'pending'"
|
:can-cancel="selected?.status === 'pending'"
|
||||||
@cancelled="reload"
|
@cancelled="reload"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">
|
<PageHeader>
|
||||||
{{ $t("absences.teamTitle") }}
|
{{ $t("absences.teamTitle") }}
|
||||||
</h1>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
@@ -189,6 +190,7 @@
|
|||||||
:user="selectedEmployee"
|
:user="selectedEmployee"
|
||||||
@saved="loadEmployees"
|
@saved="loadEmployees"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="default">
|
<NuxtLayout name="default">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
<PageHeader>{{ $t('profile.title') }}</PageHeader>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
<!-- Current avatar -->
|
<!-- Current avatar -->
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ client?.name ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="client">
|
<template v-else-if="client">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">
|
<PageHeader>
|
||||||
{{ $t('directory.title') }}
|
{{ $t('directory.title') }}
|
||||||
</h1>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<!-- Clients -->
|
<!-- Clients -->
|
||||||
<template #clients>
|
<template #clients>
|
||||||
@@ -171,6 +172,7 @@
|
|||||||
:message="deleteModalMessage"
|
:message="deleteModalMessage"
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prestataire?.name ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ prestataire?.name ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="prestataire">
|
<template v-else-if="prestataire">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.company ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ prospect?.company ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="prospect">
|
<template v-else-if="prospect">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -126,6 +129,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -95,11 +95,13 @@ function handleTaskLinked(_taskId: number): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col overflow-hidden">
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
<div class="flex-shrink-0">
|
||||||
<h1 class="text-lg font-semibold text-neutral-900">
|
<PageHeader>
|
||||||
{{ t('mail.title') }}
|
{{ t('mail.title') }}
|
||||||
</h1>
|
<template #actions>
|
||||||
<MailRefreshButton />
|
<MailRefreshButton />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -355,9 +355,9 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<!-- Header + Filters -->
|
<!-- Header + Filters -->
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between gap-3">
|
{{ $t('myTasks.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
@@ -378,78 +378,79 @@ onMounted(async () => {
|
|||||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedProjectId"
|
||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
label="Projet"
|
label="Projet"
|
||||||
:empty-option-label="$t('myTasks.allProjects')"
|
:empty-option-label="$t('myTasks.allProjects')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedGroupId"
|
v-model="selectedGroupId"
|
||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
:empty-option-label="$t('myTasks.allGroups')"
|
:empty-option-label="$t('myTasks.allGroups')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedTagId"
|
||||||
:options="tagOptions"
|
:options="tagOptions"
|
||||||
label="Type"
|
label="Type"
|
||||||
:empty-option-label="$t('myTasks.allTypes')"
|
:empty-option-label="$t('myTasks.allTypes')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPriorityId"
|
v-model="selectedPriorityId"
|
||||||
:options="priorityOptions"
|
:options="priorityOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
:empty-option-label="$t('myTasks.allPriorities')"
|
:empty-option-label="$t('myTasks.allPriorities')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedEffortId"
|
v-model="selectedEffortId"
|
||||||
:options="effortOptions"
|
:options="effortOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
:empty-option-label="$t('myTasks.allEfforts')"
|
:empty-option-label="$t('myTasks.allEfforts')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedAssigneeId"
|
v-model="selectedAssigneeId"
|
||||||
:options="assigneeOptions"
|
:options="assigneeOptions"
|
||||||
label="Assigné"
|
label="Assigné"
|
||||||
:empty-option-label="$t('myTasks.allAssignees')"
|
:empty-option-label="$t('myTasks.allAssignees')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="sortById"
|
v-model="sortById"
|
||||||
:options="sortOptions"
|
:options="sortOptions"
|
||||||
:label="$t('myTasks.sortBy')"
|
:label="$t('myTasks.sortBy')"
|
||||||
:empty-option-label="$t('myTasks.sortDefault')"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Kanban View — grouped by canonical category -->
|
<!-- 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="flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="cat in CATEGORIES"
|
v-for="cat in CATEGORIES"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
@@ -509,7 +510,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
<div v-if="viewMode === 'list'" class="flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||||
<TaskBulkActions
|
<TaskBulkActions
|
||||||
:selected-count="selectedTaskIds.size"
|
:selected-count="selectedTaskIds.size"
|
||||||
:total-count="tasks.length"
|
:total-count="tasks.length"
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ project?.name ?? '' }} — {{ $t('archive.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
<template #subheader>
|
||||||
</div>
|
<div class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
<div class="mt-4">
|
v-model="selectedGroupId"
|
||||||
<MalioSelect
|
:options="groupFilterOptions"
|
||||||
v-model="selectedGroupId"
|
label="Groupe"
|
||||||
:options="groupFilterOptions"
|
empty-option-label="Tous les groupes"
|
||||||
label="Groupe"
|
group-class="w-64"
|
||||||
empty-option-label="Tous les groupes"
|
/>
|
||||||
group-class="w-64"
|
</div>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>{{ project?.name ?? '' }} — Groupes</PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ProjectGroupTab :project-id="projectId" />
|
<ProjectGroupTab :project-id="projectId" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between gap-3">
|
{{ project?.name ?? '' }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
@@ -30,66 +30,67 @@
|
|||||||
@click="projectDrawerOpen = true"
|
@click="projectDrawerOpen = true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedGroupId"
|
v-model="selectedGroupId"
|
||||||
:options="groupFilterOptions"
|
:options="groupFilterOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Tous les groupes"
|
empty-option-label="Tous les groupes"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedTagId"
|
||||||
:options="tagFilterOptions"
|
:options="tagFilterOptions"
|
||||||
label="Tags"
|
label="Tags"
|
||||||
empty-option-label="Tous les tags"
|
empty-option-label="Tous les tags"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedAssigneeId"
|
v-model="selectedAssigneeId"
|
||||||
:options="userFilterOptions"
|
:options="userFilterOptions"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="Tous les users"
|
empty-option-label="Tous les users"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
empty-option-label="Tous les status"
|
empty-option-label="Tous les status"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPriorityId"
|
v-model="selectedPriorityId"
|
||||||
:options="priorityFilterOptions"
|
:options="priorityFilterOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
empty-option-label="Toutes"
|
empty-option-label="Toutes"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedEffortId"
|
v-model="selectedEffortId"
|
||||||
:options="effortFilterOptions"
|
:options="effortFilterOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Kanban -->
|
<!-- Kanban -->
|
||||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
{{ $t('projects.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||||
</MalioButton>
|
</MalioButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,60 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
{{ $t('reporting.title') }}
|
||||||
{{ $t('reporting.title') }}
|
<template #subheader>
|
||||||
</h1>
|
<!-- Filters -->
|
||||||
|
<div class="mt-4 flex flex-wrap items-end gap-3">
|
||||||
<!-- Filters -->
|
<MalioSelect
|
||||||
<div class="mt-4 flex flex-wrap items-end gap-3">
|
v-model="selectedPeriod"
|
||||||
<MalioSelect
|
:options="periodOptions"
|
||||||
v-model="selectedPeriod"
|
:label="$t('reporting.filters.period')"
|
||||||
:options="periodOptions"
|
group-class="!w-48"
|
||||||
:label="$t('reporting.filters.period')"
|
text-field="text-sm"
|
||||||
group-class="!w-48"
|
text-value="text-sm"
|
||||||
text-field="text-sm"
|
/>
|
||||||
text-value="text-sm"
|
<div class="w-40">
|
||||||
/>
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<div class="w-40">
|
{{ $t('reporting.filters.from') }}
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
</label>
|
||||||
{{ $t('reporting.filters.from') }}
|
<MalioDate
|
||||||
</label>
|
v-model="customFrom"
|
||||||
<MalioDate
|
:disabled="selectedPeriod !== 'custom'"
|
||||||
v-model="customFrom"
|
group-class="w-full"
|
||||||
:disabled="selectedPeriod !== 'custom'"
|
/>
|
||||||
group-class="w-full"
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
|
{{ $t('reporting.filters.to') }}
|
||||||
|
</label>
|
||||||
|
<MalioDate
|
||||||
|
v-model="customTo"
|
||||||
|
:disabled="selectedPeriod !== 'custom'"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
:label="$t('reporting.filters.project')"
|
||||||
|
:empty-option-label="$t('reporting.filters.allProjects')"
|
||||||
|
group-class="!w-44"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
:label="$t('reporting.filters.user')"
|
||||||
|
:empty-option-label="$t('reporting.filters.allUsers')"
|
||||||
|
group-class="!w-44"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
</template>
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
</PageHeader>
|
||||||
{{ $t('reporting.filters.to') }}
|
|
||||||
</label>
|
|
||||||
<MalioDate
|
|
||||||
v-model="customTo"
|
|
||||||
:disabled="selectedPeriod !== 'custom'"
|
|
||||||
group-class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="selectedProjectId"
|
|
||||||
:options="projectOptions"
|
|
||||||
:label="$t('reporting.filters.project')"
|
|
||||||
:empty-option-label="$t('reporting.filters.allProjects')"
|
|
||||||
group-class="!w-44"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="selectedUserId"
|
|
||||||
:options="userOptions"
|
|
||||||
:label="$t('reporting.filters.user')"
|
|
||||||
:empty-option-label="$t('reporting.filters.allUsers')"
|
|
||||||
group-class="!w-44"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,101 +1,104 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
<div ref="pageHeaderEl" class="flex-shrink-0">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
Suivi des temps
|
||||||
<MalioButton
|
<template #actions>
|
||||||
icon-name="mdi:plus"
|
<MalioButton
|
||||||
icon-position="left"
|
icon-name="mdi:plus"
|
||||||
button-class="shrink-0"
|
icon-position="left"
|
||||||
@click="openCreateDrawer()"
|
button-class="shrink-0"
|
||||||
>
|
@click="openCreateDrawer()"
|
||||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
|
||||||
<span class="sm:hidden">Activité</span>
|
|
||||||
</MalioButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
|
||||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:chevron-left"
|
|
||||||
aria-label="Précédent"
|
|
||||||
variant="ghost"
|
|
||||||
@click="navigatePrev"
|
|
||||||
/>
|
|
||||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
|
||||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
|
||||||
{{ currentMonthLabel }}
|
|
||||||
</h2>
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:chevron-right"
|
|
||||||
aria-label="Suivant"
|
|
||||||
variant="ghost"
|
|
||||||
@click="navigateNext"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
|
||||||
<button
|
|
||||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
|
||||||
:key="mode"
|
|
||||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
|
||||||
:class="viewMode === mode
|
|
||||||
? 'bg-primary-500 text-white shadow-sm'
|
|
||||||
: 'text-neutral-500 hover:text-neutral-700'"
|
|
||||||
@click="viewMode = mode"
|
|
||||||
>
|
>
|
||||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||||
</button>
|
<span class="sm:hidden">Activité</span>
|
||||||
</div>
|
</MalioButton>
|
||||||
|
</template>
|
||||||
|
<template #subheader>
|
||||||
|
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||||
|
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:chevron-left"
|
||||||
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigatePrev"
|
||||||
|
/>
|
||||||
|
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||||
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||||
|
{{ currentMonthLabel }}
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:chevron-right"
|
||||||
|
aria-label="Suivant"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigateNext"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||||
<MalioSelect
|
<button
|
||||||
v-model="selectedUserId"
|
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||||
:options="userOptions"
|
:key="mode"
|
||||||
group-class="!w-36 sm:!w-44"
|
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||||
text-field="text-sm"
|
:class="viewMode === mode
|
||||||
text-value="text-sm"
|
? 'bg-primary-500 text-white shadow-sm'
|
||||||
label="User"
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
empty-option-label="Tous"
|
@click="viewMode = mode"
|
||||||
/>
|
>
|
||||||
</div>
|
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedUserId"
|
||||||
:options="projectOptions"
|
:options="userOptions"
|
||||||
empty-option-label="Tous"
|
group-class="!w-36 sm:!w-44"
|
||||||
label="Projet"
|
text-field="text-sm"
|
||||||
group-class="!w-36 sm:!w-44"
|
text-value="text-sm"
|
||||||
text-field="text-sm"
|
label="User"
|
||||||
text-value="text-sm"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedProjectId"
|
||||||
:options="tagOptions"
|
:options="projectOptions"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
label="Tag"
|
label="Projet"
|
||||||
group-class="!w-36 sm:!w-44"
|
group-class="!w-36 sm:!w-44"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioButton
|
<div class="[&>div]:!mt-0">
|
||||||
:label="$t('timeEntries.export')"
|
<MalioSelect
|
||||||
variant="secondary"
|
v-model="selectedTagId"
|
||||||
icon-name="mdi:download"
|
:options="tagOptions"
|
||||||
icon-position="left"
|
empty-option-label="Tous"
|
||||||
button-class="w-auto px-4"
|
label="Tag"
|
||||||
@click="exportDrawerOpen = true"
|
group-class="!w-36 sm:!w-44"
|
||||||
/>
|
text-field="text-sm"
|
||||||
</div>
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('timeEntries.export')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:download"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="exportDrawerOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
<div class="relative z-0 -mb-24 min-h-0 flex-1">
|
||||||
<TimeEntryList
|
<TimeEntryList
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
:entries="filteredEntries"
|
:entries="filteredEntries"
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "nuxt-app",
|
"name": "nuxt-app",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.5",
|
"@malio/layer-ui": "^1.7.16",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -2220,9 +2220,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.7.5",
|
"version": "1.7.16",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.5/layer-ui-1.7.5.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.16/layer-ui-1.7.16.tgz",
|
||||||
"integrity": "sha512-xryrAYgVgX3eurEWXT/d0p4r/MBYNBB3mBnvV6xVcFhzxW+HfOra8hsVHLvrCtd+m5E1t7PDRzjw1FObkV6fdQ==",
|
"integrity": "sha512-24scQzhfnwLJr+JTlusiiazkjEK8pqwPp5NZGLdFbP32f+J9RpwoJf/U0ztwIJssXEeYvJB4cdLDYow7dZJv6Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.5",
|
"@malio/layer-ui": "^1.7.16",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
+20
-19
@@ -1,24 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
|
Administration
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||||
<nav class="flex gap-4 sm:gap-6">
|
<nav class="flex gap-4 sm:gap-6">
|
||||||
<button
|
<button
|
||||||
v-for="tab in visibleTabs"
|
v-for="tab in visibleTabs"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||||
:class="activeTab === tab.key
|
:class="activeTab === tab.key
|
||||||
? 'border-b-2 border-primary-500 text-primary-500'
|
? 'border-b-2 border-primary-500 text-primary-500'
|
||||||
: 'text-neutral-500 hover:text-neutral-700'"
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
@click="activeTab = tab.key"
|
@click="activeTab = tab.key"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>
|
<PageHeader>{{ $t('sharedFiles.title') }}</PageHeader>
|
||||||
|
|
||||||
<!-- Fil d'Ariane -->
|
<!-- Fil d'Ariane -->
|
||||||
<nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
<nav class="flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Filtre local + rechargement -->
|
<!-- Filtre local + rechargement -->
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="max-w-sm flex-1">
|
<div class="max-w-sm flex-1">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="filter"
|
v-model="filter"
|
||||||
|
|||||||
+35
-34
@@ -506,39 +506,40 @@ const lineOptions = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
{{ $t('dashboard.title') }}
|
||||||
|
<template #subheader>
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPeriod"
|
v-model="selectedPeriod"
|
||||||
:options="periodOptions"
|
:options="periodOptions"
|
||||||
:label="$t('dashboard.filters.period')"
|
:label="$t('dashboard.filters.period')"
|
||||||
group-class="!w-48"
|
group-class="!w-48"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedProjectId"
|
||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
:label="$t('dashboard.filters.project')"
|
:label="$t('dashboard.filters.project')"
|
||||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedUserId"
|
v-model="selectedUserId"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
:label="$t('dashboard.filters.user')"
|
:label="$t('dashboard.filters.user')"
|
||||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||||
@@ -547,7 +548,7 @@ const lineOptions = {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- KPI Cards -->
|
<!-- KPI Cards -->
|
||||||
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||||
{{ $t('dashboard.stats.hoursPeriod') }}
|
{{ $t('dashboard.stats.hoursPeriod') }}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Address;
|
|||||||
interface AddressRepositoryInterface
|
interface AddressRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Address;
|
public function findById(int $id): ?Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return Address[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\CommercialReport;
|
|||||||
interface CommercialReportRepositoryInterface
|
interface CommercialReportRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?CommercialReport;
|
public function findById(int $id): ?CommercialReport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return CommercialReport[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Contact;
|
|||||||
interface ContactRepositoryInterface
|
interface ContactRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Contact;
|
public function findById(int $id): ?Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return Contact[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Address;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-address',
|
||||||
|
description: 'Create an address (admin) attached to exactly one of clientId / prospectId / prestataireId. Country defaults to FR (ISO 3166 alpha-2).'
|
||||||
|
)]
|
||||||
|
class CreateAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $label = null,
|
||||||
|
?string $street = null,
|
||||||
|
?string $streetComplement = null,
|
||||||
|
?string $postalCode = null,
|
||||||
|
?string $city = null,
|
||||||
|
?string $country = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = new Address();
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$address->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$address->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$address->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
$address->setLabel($label);
|
||||||
|
$address->setStreet($street);
|
||||||
|
$address->setStreetComplement($streetComplement);
|
||||||
|
$address->setPostalCode($postalCode);
|
||||||
|
$address->setCity($city);
|
||||||
|
if (null !== $country) {
|
||||||
|
if (2 !== strlen($country)) {
|
||||||
|
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
|
||||||
|
}
|
||||||
|
$address->setCountry(strtoupper($country));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($address);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Exception;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-commercial-report',
|
||||||
|
description: 'Create a commercial report (admin) attached to exactly one of clientId / prospectId / prestataireId. Type defaults to "note". Allowed types: note, call, meeting, email. Date defaults to today if omitted.'
|
||||||
|
)]
|
||||||
|
class CreateCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
string $subject,
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $body = null,
|
||||||
|
?string $occurredAt = null,
|
||||||
|
?string $type = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = new CommercialReport();
|
||||||
|
$report->setSubject($subject);
|
||||||
|
$report->setBody($body);
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$report->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$report->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$report->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = null === $occurredAt
|
||||||
|
? new DateTimeImmutable('today')
|
||||||
|
: new DateTimeImmutable($occurredAt);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
|
||||||
|
}
|
||||||
|
$report->setOccurredAt($date);
|
||||||
|
|
||||||
|
if (null !== $type) {
|
||||||
|
$typeEnum = ReportType::tryFrom($type);
|
||||||
|
if (null === $typeEnum) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
|
||||||
|
}
|
||||||
|
$report->setType($typeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($report);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Contact;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-contact',
|
||||||
|
description: 'Create a contact (admin) attached to exactly one of clientId / prospectId / prestataireId. All fields except the parent are optional.'
|
||||||
|
)]
|
||||||
|
class CreateContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $firstName = null,
|
||||||
|
?string $lastName = null,
|
||||||
|
?string $jobTitle = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phonePrimary = null,
|
||||||
|
?string $phoneSecondary = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = new Contact();
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$contact->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$contact->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$contact->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact->setFirstName($firstName);
|
||||||
|
$contact->setLastName($lastName);
|
||||||
|
$contact->setJobTitle($jobTitle);
|
||||||
|
$contact->setEmail($email);
|
||||||
|
$contact->setPhonePrimary($phonePrimary);
|
||||||
|
$contact->setPhoneSecondary($phoneSecondary);
|
||||||
|
|
||||||
|
$this->entityManager->persist($contact);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(name: 'create-prestataire', description: 'Create a prestataire / service provider (admin). Only name is required.')]
|
||||||
|
class CreatePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
string $name,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phone = null,
|
||||||
|
?string $website = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = new Prestataire();
|
||||||
|
$prestataire->setName($name);
|
||||||
|
$prestataire->setEmail($email);
|
||||||
|
$prestataire->setPhone($phone);
|
||||||
|
$prestataire->setWebsite($website);
|
||||||
|
|
||||||
|
$this->entityManager->persist($prestataire);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::prestataire($prestataire));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-address', description: 'Delete an address (admin).')]
|
||||||
|
class DeleteAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($address);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Address #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-commercial-report', description: 'Delete a commercial report (admin). Cascade removes its attached documents.')]
|
||||||
|
class DeleteCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($report);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('CommercialReport #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-contact', description: 'Delete a contact (admin).')]
|
||||||
|
class DeleteContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($contact);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Contact #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-prestataire', description: 'Delete a prestataire (admin). Cascade removes its contacts, addresses and reports.')]
|
||||||
|
class DeletePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $prestataire->getName();
|
||||||
|
$this->entityManager->remove($prestataire);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Prestataire "%s" deleted.', $name)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-address', description: 'Get an address by ID.')]
|
||||||
|
class GetAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
|
#[McpTool(name: 'get-client', description: 'Get a client by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
class GetClientTool
|
class GetClientTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClientRepositoryInterface $clientRepository,
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +38,20 @@ class GetClientTool
|
|||||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(Serializer::client($client));
|
$payload = Serializer::client($client);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['client' => $client], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['client' => $client], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['client' => $client], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-commercial-report', description: 'Get a commercial report by ID, including its attached documents.')]
|
||||||
|
class GetCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-contact', description: 'Get a contact by ID.')]
|
||||||
|
class GetContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-prestataire', description: 'Get a prestataire by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
|
class GetPrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = Serializer::prestataire($prestataire);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['prestataire' => $prestataire], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['prestataire' => $prestataire], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['prestataire' => $prestataire], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
|
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
class GetProspectTool
|
class GetProspectTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +38,20 @@ class GetProspectTool
|
|||||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(Serializer::prospect($prospect));
|
$payload = Serializer::prospect($prospect);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['prospect' => $prospect], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['prospect' => $prospect], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['prospect' => $prospect], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-addresses',
|
||||||
|
description: 'List addresses, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||||
|
)]
|
||||||
|
class ListAddressesTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$addresses = $this->addressRepository->findBy($criteria, ['id' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($a) => Serializer::address($a), $addresses));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-commercial-reports',
|
||||||
|
description: 'List commercial reports, optionally filtered by parent (at most one of clientId / prospectId / prestataireId). Returns reports ordered by occurredAt DESC.'
|
||||||
|
)]
|
||||||
|
class ListCommercialReportsTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reports = $this->reportRepository->findBy($criteria, ['occurredAt' => 'DESC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($r) => Serializer::commercialReport($r), $reports));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-contacts',
|
||||||
|
description: 'List contacts, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||||
|
)]
|
||||||
|
class ListContactsTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->contactRepository->findBy($criteria, ['lastName' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($c) => Serializer::contact($c), $contacts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(name: 'list-prestataires', description: 'List all prestataires with their IDs, names, and emails. Use this to discover valid prestataire IDs.')]
|
||||||
|
class ListPrestatairesTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataires = $this->prestataireRepository->findBy([], ['name' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($p) => [
|
||||||
|
'id' => $p->getId(),
|
||||||
|
'name' => $p->getName(),
|
||||||
|
'email' => $p->getEmail(),
|
||||||
|
], $prestataires));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-address',
|
||||||
|
description: 'Update an address (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
|
||||||
|
)]
|
||||||
|
class UpdateAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $label = null,
|
||||||
|
?string $street = null,
|
||||||
|
?string $streetComplement = null,
|
||||||
|
?string $postalCode = null,
|
||||||
|
?string $city = null,
|
||||||
|
?string $country = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $label) {
|
||||||
|
$address->setLabel($label);
|
||||||
|
}
|
||||||
|
if (null !== $street) {
|
||||||
|
$address->setStreet($street);
|
||||||
|
}
|
||||||
|
if (null !== $streetComplement) {
|
||||||
|
$address->setStreetComplement($streetComplement);
|
||||||
|
}
|
||||||
|
if (null !== $postalCode) {
|
||||||
|
$address->setPostalCode($postalCode);
|
||||||
|
}
|
||||||
|
if (null !== $city) {
|
||||||
|
$address->setCity($city);
|
||||||
|
}
|
||||||
|
if (null !== $country) {
|
||||||
|
if (2 !== strlen($country)) {
|
||||||
|
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
|
||||||
|
}
|
||||||
|
$address->setCountry(strtoupper($country));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Exception;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-commercial-report',
|
||||||
|
description: 'Update a commercial report (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
|
||||||
|
)]
|
||||||
|
class UpdateCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $subject = null,
|
||||||
|
?string $body = null,
|
||||||
|
?string $occurredAt = null,
|
||||||
|
?string $type = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $subject) {
|
||||||
|
$report->setSubject($subject);
|
||||||
|
}
|
||||||
|
if (null !== $body) {
|
||||||
|
$report->setBody($body);
|
||||||
|
}
|
||||||
|
if (null !== $occurredAt) {
|
||||||
|
try {
|
||||||
|
$report->setOccurredAt(new DateTimeImmutable($occurredAt));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (null !== $type) {
|
||||||
|
$typeEnum = ReportType::tryFrom($type);
|
||||||
|
if (null === $typeEnum) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
|
||||||
|
}
|
||||||
|
$report->setType($typeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-contact',
|
||||||
|
description: 'Update a contact (admin). Only provided fields change. The parent (client/prospect/prestataire) is immutable — delete then recreate to re-attach.'
|
||||||
|
)]
|
||||||
|
class UpdateContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $firstName = null,
|
||||||
|
?string $lastName = null,
|
||||||
|
?string $jobTitle = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phonePrimary = null,
|
||||||
|
?string $phoneSecondary = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $firstName) {
|
||||||
|
$contact->setFirstName($firstName);
|
||||||
|
}
|
||||||
|
if (null !== $lastName) {
|
||||||
|
$contact->setLastName($lastName);
|
||||||
|
}
|
||||||
|
if (null !== $jobTitle) {
|
||||||
|
$contact->setJobTitle($jobTitle);
|
||||||
|
}
|
||||||
|
if (null !== $email) {
|
||||||
|
$contact->setEmail($email);
|
||||||
|
}
|
||||||
|
if (null !== $phonePrimary) {
|
||||||
|
$contact->setPhonePrimary($phonePrimary);
|
||||||
|
}
|
||||||
|
if (null !== $phoneSecondary) {
|
||||||
|
$contact->setPhoneSecondary($phoneSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'update-prestataire', description: 'Update a prestataire (admin). Only provided fields change.')]
|
||||||
|
class UpdatePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $name = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phone = null,
|
||||||
|
?string $website = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $name) {
|
||||||
|
$prestataire->setName($name);
|
||||||
|
}
|
||||||
|
if (null !== $email) {
|
||||||
|
$prestataire->setEmail($email);
|
||||||
|
}
|
||||||
|
if (null !== $phone) {
|
||||||
|
$prestataire->setPhone($phone);
|
||||||
|
}
|
||||||
|
if (null !== $website) {
|
||||||
|
$prestataire->setWebsite($website);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::prestataire($prestataire));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@ use App\Module\Absence\Domain\Entity\AbsenceBalance;
|
|||||||
use App\Module\Absence\Domain\Entity\AbsencePolicy;
|
use App\Module\Absence\Domain\Entity\AbsencePolicy;
|
||||||
use App\Module\Absence\Domain\Entity\AbsenceRequest;
|
use App\Module\Absence\Domain\Entity\AbsenceRequest;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Address;
|
||||||
use App\Module\Directory\Domain\Entity\Client;
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||||
|
use App\Module\Directory\Domain\Entity\Contact;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
use App\Module\Directory\Domain\Entity\Prospect;
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
|
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
|
||||||
@@ -374,6 +379,98 @@ final class Serializer
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function prestataire(Prestataire $p): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $p->getId(),
|
||||||
|
'name' => $p->getName(),
|
||||||
|
'email' => $p->getEmail(),
|
||||||
|
'phone' => $p->getPhone(),
|
||||||
|
'website' => $p->getWebsite(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function contact(Contact $c): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $c->getId(),
|
||||||
|
'firstName' => $c->getFirstName(),
|
||||||
|
'lastName' => $c->getLastName(),
|
||||||
|
'jobTitle' => $c->getJobTitle(),
|
||||||
|
'email' => $c->getEmail(),
|
||||||
|
'phonePrimary' => $c->getPhonePrimary(),
|
||||||
|
'phoneSecondary' => $c->getPhoneSecondary(),
|
||||||
|
'clientId' => $c->getClient()?->getId(),
|
||||||
|
'prospectId' => $c->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $c->getPrestataire()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function address(Address $a): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $a->getId(),
|
||||||
|
'label' => $a->getLabel(),
|
||||||
|
'street' => $a->getStreet(),
|
||||||
|
'streetComplement' => $a->getStreetComplement(),
|
||||||
|
'postalCode' => $a->getPostalCode(),
|
||||||
|
'city' => $a->getCity(),
|
||||||
|
'country' => $a->getCountry(),
|
||||||
|
'clientId' => $a->getClient()?->getId(),
|
||||||
|
'prospectId' => $a->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $a->getPrestataire()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reportDocument(ReportDocument $d): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $d->getId(),
|
||||||
|
'originalName' => $d->getOriginalName(),
|
||||||
|
'mimeType' => $d->getMimeType(),
|
||||||
|
'size' => $d->getSize(),
|
||||||
|
'createdAt' => $d->getCreatedAt()?->format('c'),
|
||||||
|
'uploadedBy' => self::user($d->getUploadedBy()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function commercialReport(CommercialReport $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $r->getId(),
|
||||||
|
'subject' => $r->getSubject(),
|
||||||
|
'body' => $r->getBody(),
|
||||||
|
'occurredAt' => $r->getOccurredAt()?->format('Y-m-d'),
|
||||||
|
'type' => $r->getType()->value,
|
||||||
|
'typeLabel' => $r->getType()->label(),
|
||||||
|
'author' => self::user($r->getAuthor()),
|
||||||
|
'clientId' => $r->getClient()?->getId(),
|
||||||
|
'prospectId' => $r->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $r->getPrestataire()?->getId(),
|
||||||
|
'documents' => array_map(
|
||||||
|
fn (ReportDocument $d) => self::reportDocument($d),
|
||||||
|
$r->getDocuments()->toArray()
|
||||||
|
),
|
||||||
|
'createdAt' => $r->getCreatedAt()?->format('c'),
|
||||||
|
'updatedAt' => $r->getUpdatedAt()?->format('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListAddressesTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateAddressTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class AddressLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-address-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())(null, null, null, 'Home');
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())($this->client->getId(), null, $this->prestataire->getId(), 'Dup');
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateCountryDefaultsToFRWhenOmitted(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ'), true);
|
||||||
|
|
||||||
|
self::assertSame('FR', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsNonIso3166Country(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'France');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateNormalizesCountryToUppercase(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'be'), true);
|
||||||
|
|
||||||
|
self::assertSame('BE', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateOnEachParentWorks(): void
|
||||||
|
{
|
||||||
|
$clientAddr = json_decode(($this->createTool())($this->client->getId(), null, null, 'CHQ'), true);
|
||||||
|
self::assertSame($this->client->getId(), $clientAddr['clientId']);
|
||||||
|
self::assertNull($clientAddr['prospectId']);
|
||||||
|
|
||||||
|
$prospectAddr = json_decode(($this->createTool())(null, $this->prospect->getId(), null, 'PHQ'), true);
|
||||||
|
self::assertSame($this->prospect->getId(), $prospectAddr['prospectId']);
|
||||||
|
|
||||||
|
$prestAddr = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'XHQ'), true);
|
||||||
|
self::assertSame($this->prestataire->getId(), $prestAddr['prestataireId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsAddress(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Office', '1 rue X', null, '75001', 'Paris', 'FR'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('Office', $data['label']);
|
||||||
|
self::assertSame('1 rue X', $data['street']);
|
||||||
|
self::assertSame('75001', $data['postalCode']);
|
||||||
|
self::assertSame('Paris', $data['city']);
|
||||||
|
self::assertSame('FR', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListFilteredByClient(): void
|
||||||
|
{
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'A');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'B');
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'Z');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
self::assertSame('A', $data[0]['label']);
|
||||||
|
self::assertSame('B', $data[1]['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateRejectsNonIso3166Country(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'X'), true);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
|
||||||
|
($this->updateTool())((int) $created['id'], null, null, null, null, null, 'Belgium');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Old', '1 rue X', null, '75001', 'Paris', 'FR'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, '75002', null, 'be'), true);
|
||||||
|
|
||||||
|
self::assertSame('New', $data['label']); // changed
|
||||||
|
self::assertSame('1 rue X', $data['street']); // unchanged
|
||||||
|
self::assertSame('75002', $data['postalCode']); // changed
|
||||||
|
self::assertSame('Paris', $data['city']); // unchanged
|
||||||
|
self::assertSame('BE', $data['country']); // changed + uppercased
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesAddress(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(AddressRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateAddressTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateAddressTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetAddressTool
|
||||||
|
{
|
||||||
|
return new GetAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListAddressesTool
|
||||||
|
{
|
||||||
|
return new ListAddressesTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateAddressTool
|
||||||
|
{
|
||||||
|
return new UpdateAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteAddressTool
|
||||||
|
{
|
||||||
|
return new DeleteAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListCommercialReportsTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateCommercialReportTool;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class CommercialReportLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-report-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())('subject', null, null, null);
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, $this->prestataire->getId());
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsInvalidType(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid type "lunch". Allowed: note, call, meeting, email.');
|
||||||
|
($this->createTool())('Lunch at noon', $this->client->getId(), null, null, null, null, 'lunch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAcceptsAllValidTypes(): void
|
||||||
|
{
|
||||||
|
foreach (['note', 'call', 'meeting', 'email'] as $type) {
|
||||||
|
$data = json_decode(
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, null, null, '2026-01-15', $type),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
self::assertSame($type, $data['type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateDefaultsTypeToNote(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertSame(ReportType::Note->value, $data['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsInvalidDate(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid occurredAt "not-a-date"');
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, null, null, 'not-a-date');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateDefaultsOccurredAtToToday(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertSame(new DateTimeImmutable('today')->format('Y-m-d'), $data['occurredAt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAutoFillsAuthorFromCurrentUser(): void
|
||||||
|
{
|
||||||
|
$this->loginAdmin();
|
||||||
|
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertNotNull($data['author']);
|
||||||
|
self::assertSame($this->admin->getId(), $data['author']['id']);
|
||||||
|
self::assertSame($this->admin->getUsername(), $data['author']['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsReport(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(
|
||||||
|
($this->createTool())('My subject', $this->prestataire->getId() ? null : null, null, $this->prestataire->getId(), 'body text', '2026-03-01', 'meeting'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('My subject', $data['subject']);
|
||||||
|
self::assertSame('body text', $data['body']);
|
||||||
|
self::assertSame('2026-03-01', $data['occurredAt']);
|
||||||
|
self::assertSame('meeting', $data['type']);
|
||||||
|
self::assertSame($this->prestataire->getId(), $data['prestataireId']);
|
||||||
|
self::assertSame([], $data['documents']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListOrderedByOccurredAtDesc(): void
|
||||||
|
{
|
||||||
|
($this->createTool())('oldest', $this->client->getId(), null, null, null, '2026-01-01');
|
||||||
|
($this->createTool())('newest', $this->client->getId(), null, null, null, '2026-12-01');
|
||||||
|
($this->createTool())('middle', $this->client->getId(), null, null, null, '2026-06-15');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertCount(3, $data);
|
||||||
|
self::assertSame('newest', $data[0]['subject']);
|
||||||
|
self::assertSame('middle', $data[1]['subject']);
|
||||||
|
self::assertSame('oldest', $data[2]['subject']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListRejectsMultipleFilters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
|
||||||
|
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateChangesTypeAndDate(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null, null, '2026-01-01', 'note'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'new subject', null, '2026-02-02', 'call'), true);
|
||||||
|
|
||||||
|
self::assertSame('new subject', $data['subject']);
|
||||||
|
self::assertSame('2026-02-02', $data['occurredAt']);
|
||||||
|
self::assertSame('call', $data['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateRejectsInvalidType(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid type "lunch"');
|
||||||
|
($this->updateTool())((int) $created['id'], null, null, null, 'lunch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesReport(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('Bye', $this->client->getId(), null, null), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(CommercialReportRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginAdmin(): void
|
||||||
|
{
|
||||||
|
$token = new UsernamePasswordToken($this->admin, 'main', $this->admin->getRoles());
|
||||||
|
self::getContainer()->get(TokenStorageInterface::class)->setToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateCommercialReportTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateCommercialReportTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetCommercialReportTool
|
||||||
|
{
|
||||||
|
return new GetCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListCommercialReportsTool
|
||||||
|
{
|
||||||
|
return new ListCommercialReportsTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateCommercialReportTool
|
||||||
|
{
|
||||||
|
return new UpdateCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteCommercialReportTool
|
||||||
|
{
|
||||||
|
return new DeleteCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListContactsTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateContactTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class ContactLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-contact-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())(null, null, null, 'Anon');
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())($this->client->getId(), $this->prospect->getId(), null, 'Dup');
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithUnknownClientThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Client with ID 999999 not found.');
|
||||||
|
($this->createTool())(999999, null, null, 'Anon');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateOnEachParentWorks(): void
|
||||||
|
{
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
['clientId', $this->client->getId()],
|
||||||
|
['prospectId', $this->prospect->getId()],
|
||||||
|
['prestataireId', $this->prestataire->getId()],
|
||||||
|
] as [$field, $id]
|
||||||
|
) {
|
||||||
|
$args = [null, null, null, 'John', 'Doe-'.$field, 'CTO', 'john@x.test'];
|
||||||
|
$idx = ['clientId' => 0, 'prospectId' => 1, 'prestataireId' => 2][$field];
|
||||||
|
$args[$idx] = $id;
|
||||||
|
|
||||||
|
$data = json_decode(($this->createTool())(...$args), true);
|
||||||
|
self::assertSame('Doe-'.$field, $data['lastName']);
|
||||||
|
self::assertSame($id, $data[$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsContact(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Jane', 'Smith'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('Jane', $data['firstName']);
|
||||||
|
self::assertSame('Smith', $data['lastName']);
|
||||||
|
self::assertSame($this->client->getId(), $data['clientId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListFilteredByPrestataire(): void
|
||||||
|
{
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'A', 'A-Last');
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'B', 'B-Last');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'Z', 'Z-Last');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())(null, null, $this->prestataire->getId()), true);
|
||||||
|
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
self::assertSame('A-Last', $data[0]['lastName']);
|
||||||
|
self::assertSame('B-Last', $data[1]['lastName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListRejectsMultipleFilters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
|
||||||
|
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'Old', 'Last', 'CTO', 'old@x.test'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, 'new@x.test'), true);
|
||||||
|
|
||||||
|
self::assertSame('New', $data['firstName']); // changed
|
||||||
|
self::assertSame('Last', $data['lastName']); // unchanged
|
||||||
|
self::assertSame('CTO', $data['jobTitle']); // unchanged
|
||||||
|
self::assertSame('new@x.test', $data['email']); // changed
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesContact(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(ContactRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateContactTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateContactTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetContactTool
|
||||||
|
{
|
||||||
|
return new GetContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListContactsTool
|
||||||
|
{
|
||||||
|
return new ListContactsTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateContactTool
|
||||||
|
{
|
||||||
|
return new UpdateContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteContactTool
|
||||||
|
{
|
||||||
|
return new DeleteContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreatePrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeletePrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetPrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListPrestatairesTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdatePrestataireTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class PrestataireLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-prest-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatePersistsAllFields(): void
|
||||||
|
{
|
||||||
|
$json = ($this->createTool(admin: true))('ACME Cleaning', 'contact@acme.example', '+33100000000', 'https://acme.example');
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertIsInt($data['id']);
|
||||||
|
self::assertSame('ACME Cleaning', $data['name']);
|
||||||
|
self::assertSame('contact@acme.example', $data['email']);
|
||||||
|
self::assertSame('+33100000000', $data['phone']);
|
||||||
|
self::assertSame('https://acme.example', $data['website']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresAdmin(): void
|
||||||
|
{
|
||||||
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
($this->createTool(admin: false))('Should not pass');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsEmptyCollectionsWhenNoChildren(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('Lonely Prest'), true);
|
||||||
|
|
||||||
|
$json = ($this->getTool(admin: true))((int) $created['id']);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertSame($created['id'], $data['id']);
|
||||||
|
self::assertSame([], $data['contacts']);
|
||||||
|
self::assertSame([], $data['addresses']);
|
||||||
|
self::assertSame([], $data['reports']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUnknownIdThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Prestataire with ID 999999 not found.');
|
||||||
|
($this->getTool(admin: true))(999999);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('Before', 'before@x.test', '+33000000000', 'https://before.test'), true);
|
||||||
|
|
||||||
|
$json = ($this->updateTool(admin: true))((int) $created['id'], null, 'after@x.test', null, null);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertSame('Before', $data['name']); // unchanged
|
||||||
|
self::assertSame('after@x.test', $data['email']); // changed
|
||||||
|
self::assertSame('+33000000000', $data['phone']); // unchanged
|
||||||
|
self::assertSame('https://before.test', $data['website']); // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListReturnsAllPrestatairesOrderedByName(): void
|
||||||
|
{
|
||||||
|
// Unique prefix isolates this test from data leaked by prior PHPUnit
|
||||||
|
// runs (DAMA rollback is not active in this project).
|
||||||
|
$prefix = 'list-test-'.uniqid().'-';
|
||||||
|
($this->createTool(admin: true))($prefix.'Zeta');
|
||||||
|
($this->createTool(admin: true))($prefix.'Alpha');
|
||||||
|
($this->createTool(admin: true))($prefix.'Mu');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool(admin: true))(), true);
|
||||||
|
$names = array_values(array_filter(
|
||||||
|
array_column($data, 'name'),
|
||||||
|
fn ($n) => str_starts_with((string) $n, $prefix),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([$prefix.'Alpha', $prefix.'Mu', $prefix.'Zeta'], $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesPrestataire(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('To be removed'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$json = ($this->deleteTool(admin: true))($id);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
self::assertStringContainsString('"To be removed"', $data['message']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(PrestataireRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(bool $admin): CreatePrestataireTool
|
||||||
|
{
|
||||||
|
return new CreatePrestataireTool(
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(bool $admin): GetPrestataireTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new GetPrestataireTool(
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$c->get(ContactRepositoryInterface::class),
|
||||||
|
$c->get(AddressRepositoryInterface::class),
|
||||||
|
$c->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(bool $admin): UpdatePrestataireTool
|
||||||
|
{
|
||||||
|
return new UpdatePrestataireTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(bool $admin): ListPrestatairesTool
|
||||||
|
{
|
||||||
|
return new ListPrestatairesTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(bool $admin): DeletePrestataireTool
|
||||||
|
{
|
||||||
|
return new DeletePrestataireTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user