Compare commits

...

18 Commits

Author SHA1 Message Date
gitea-actions 93852875ad chore: bump version to v0.4.48
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m16s
2026-06-27 13:30:06 +00:00
tristan bbd8a38c95 feat(directory) : refonte UI du Répertoire (LST-72) (#27)
Auto Tag Develop / tag (push) Successful in 9s
Améliorations frontend de la partie **Répertoire** (Client / Prospect / Prestataire). Onglet **Rapport** retravaillé en fin de parcours ; le reste de la logique métier inchangé.

## Navigation & liste
- Onglet actif conservé au retour liste ↔ fiche (flèche app **et** navigateur) via `history.state` (hors URL) — util `historyTab.ts`
- Colonne « Action » (entête alignée) + feedback hover sur les boutons d'action
- Conversion prospect → client : modal de confirmation
- Boutons « Ajouter » : label court + taille Malio standard ; barres d'outils à hauteur homogène (plus de saut entre onglets)

## Fiches (Info / Contact / Adresse)
- Style **plat** sans box-shadow (comme Starseed)
- Champs email/téléphone : `MalioInputEmail` / `MalioInputPhone`
- Grilles en **4 colonnes** (Info + blocs)
- Boutons « Nouveau contact/adresse » en secondary ; « Enregistrer » en taille Malio ; marge form↔bouton homogène
- Bouton retour **ghost** (`mdi:arrow-left-bold`)
- **Adresse** : flux CP → ville → rue (rue conditionnée au CP+ville, cascade de reset) ; titre du bloc = libellé saisi
- Suppression d'un bloc Contact/Adresse : **modal** de confirmation (centralisée dans `useDirectoryDetail`)
- Modals (suppression, conversion) basées sur `MalioModal` (design Starseed) avec nom en gras

## Onglet Rapport
- Bouton d'ajout en taille Malio (« Ajouter »)
- Suppression compte-rendu : `ConfirmModal` partagée (remplace l'ancienne modal maison)
- Suppression d'un document joint : ajout d'une modal de confirmation
- Upload via `MalioInputUpload` ; bouton supprimer document aligné (`mdi:delete-outline` ghost)

## Divers
- `fix(auth)` : cookie JWT renommé `BEARER_LESSTIME` (collision localhost avec d'autres apps Symfony)
- `fix(infra)` : target makefile `fix-uploads-perm` (volume `uploads_data` root → upload local OK)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #27
2026-06-27 13:29:56 +00:00
gitea-actions 0ee164c302 chore: bump version to v0.4.47
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-27 09:34:38 +00:00
matthieu d56381b4b8 Merge pull request 'feat(user) : soft-delete (archivage) des utilisateurs + UI archivage/désarchivage' (#30) from fix/user-soft-delete-orphan-references into develop
Auto Tag Develop / tag (push) Successful in 11s
Reviewed-on: #30
2026-06-27 09:34:25 +00:00
matthieu edf5fcc5f5 Merge branch 'develop' into fix/user-soft-delete-orphan-references
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m12s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m23s
2026-06-27 09:32:17 +00:00
Matthieu 89ce523019 feat(user) : UI archivage/désarchivage des utilisateurs côté admin
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m18s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m29s
- badge « Archivé » et libellé barré dans la liste admin
- popup de confirmation avant archivage (rappelle que c'est réversible)
- bouton de restauration (PATCH archived:false) pour les archivés
- case « Afficher les utilisateurs archivés » (filtre ?archived=true)
- masque l'action d'archivage sur son propre compte (évite le 403)
- service users : getArchived/restore, toast remove -> users.archived
- i18n FR : clés archived/restored/badge/confirmation
2026-06-26 17:08:20 +02:00
gitea-actions 3a2b268337 chore: bump version to v0.4.46
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 44s
2026-06-26 14:52:54 +00:00
matthieu f676b217bc Merge pull request 'fix(project) : sélection du workflow à la création + filet par défaut' (#29) from fix/project-creation-workflow into develop
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #29
2026-06-26 14:52:44 +00:00
matthieu 8bebfe1595 Merge branch 'develop' into fix/project-creation-workflow
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m28s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m40s
2026-06-26 14:50:08 +00:00
Matthieu 49267ad2fb fix(project) : erreur explicite si aucun workflow à la création au lieu d'une violation NOT NULL
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (build) (pull_request) Has been cancelled
2026-06-26 16:49:33 +02:00
Matthieu d3abb584a9 fix(project) : permet de choisir un workflow à la création + filet par défaut
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 38s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
La création de projet échouait : `Project.workflow` est obligatoire mais
n'était jamais fourni (formulaire frontend, MCP create-project), tout POST
/api/projects partait en erreur de validation/contrainte NOT NULL.

- ProjectDefaultWorkflowListener (prePersist) : assigne le workflow par
  défaut quand aucun n'est fourni, couvrant API Platform, API brute et MCP.
- retrait de l'Assert\NotNull sur Project::workflow (la validation tournait
  avant le flush et empêchait le filet) ; la contrainte DB reste le garde-fou.
- CreateProjectTool (MCP) : paramètre optionnel workflowId.
- ProjectDrawer : sélecteur Workflow en création, pré-rempli sur le défaut,
  IRI envoyée dans le payload.
- tests fonctionnels : création avec et sans workflow.
2026-06-26 16:42:02 +02:00
gitea-actions 98e3990fa5 chore: bump version to v0.4.45
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 3m27s
2026-06-26 14:21:43 +00:00
matthieu 172f79d348 Merge pull request 'fix(user) : archivage au lieu de suppression + réparation des références orphelines' (#28) from fix/user-soft-delete-orphan-references into develop
Auto Tag Develop / tag (push) Successful in 11s
Reviewed-on: #28
2026-06-26 14:21:30 +00:00
matthieu f221976573 Merge branch 'develop' into fix/user-soft-delete-orphan-references
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m0s
2026-06-26 14:17:09 +00:00
Matthieu 133f205393 test(user) : couvre le soft-delete + désarchivage admin et corrige les retours de review
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
- ajoute des tests fonctionnels (archive au DELETE, exclusion de la
  collection, listing/désarchivage admin, anti-auto-archivage) et un test
  unitaire du ArchivedUserChecker
- expose un filtre BooleanFilter `archived` + bypass admin dans
  ExcludeArchivedUserExtension pour lister les archivés (?archived=true)
- rend `archived` modifiable par un admin (groupe user:write + ApiProperty
  ROLE_ADMIN) → désarchivage possible via PATCH /api/users/{id}
- RestoreMissingUsersCommand : ne compte que les insertions réelles
  (ON CONFLICT DO NOTHING n'est plus comptabilisé à tort)
- relève memory_limit des tests à 512M (boot sérialiseur API Platform)
2026-06-26 16:14:11 +02:00
Matthieu d8d755d4c5 fix(user) : archivage au lieu de suppression + réparation des références orphelines
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m1s
Un user supprimé physiquement laissait des références orphelines (task.assignee,
time entries, notifications) car les FK vers "user" ont été créées NOT VALID lors
du refactor modular-monolith : elles n'ont jamais nettoyé les orphelins legacy. La
sérialisation API Platform d'une tâche embarquant un assignee inexistant levait une
EntityNotFoundException non rattrapable (HTTP 500 sur tout PATCH/GET de ces tickets).

- User::$archived (bool) + migration (soft delete)
- Delete de User -> UserArchiveProcessor : archive (archived=true, apiToken vidé)
  au lieu de supprimer, préservant l'intégrité référentielle
- ArchivedUserChecker : login bloqué pour un user archivé (firewalls login + api)
- ExcludeArchivedUserExtension : archivés exclus de GET /api/users (assignation),
  les références existantes restent sérialisées normalement
- Commande app:restore-missing-users : recrée (en archivés) les users encore
  référencés mais supprimés, restaurant l'intégrité sans perte de données.
  Idempotente, option --dry-run. À lancer une fois en prod après déploiement.
2026-06-26 15:51:27 +02:00
gitea-actions 3ea1a31784 chore: bump version to v0.4.44
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m42s
2026-06-25 20:53:41 +00:00
matthieu a2dcab6ec1 docs : config MCP HTTP par poste (token local, Fedora/Windows) + rotation token
Auto Tag Develop / tag (push) Successful in 8s
Le serveur MCP HTTP (token Bearer) se configure dans la config Claude Code
locale (jamais dans le .mcp.json versionné). Ajout de la méthode cross-OS
(claude mcp add), des emplacements de fichier par OS (Fedora/Linux, Windows,
macOS) et de la procédure de régénération du token (invalidé au reseed BDD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:53:31 +02:00
39 changed files with 1450 additions and 331 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon - **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER` - **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER_LESSTIME` (nommé par app pour éviter la collision avec d'autres apps Symfony sur `localhost` en dev)
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435) - **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
## Structure ## Structure
+42 -10
View File
@@ -190,7 +190,7 @@ Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker
Toutes les routes API sont préfixées `/api` (API Platform). Toutes les routes API sont préfixées `/api` (API Platform).
- Documentation auto-générée : **http://localhost:8082/api** - Documentation auto-générée : **http://localhost:8082/api**
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER` - Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER_LESSTIME`
## Serveur MCP ## Serveur MCP
@@ -219,28 +219,60 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
} }
``` ```
### Configuration réseau (HTTP) ### Configuration réseau (HTTP) — par poste, hors git
Le transport HTTP nécessite un **token API** (Bearer), qui est un **secret** : il ne va **jamais**
dans le `.mcp.json` versionné (celui-ci ne contient que le serveur STDIO local, sans secret).
Chaque développeur configure le serveur HTTP dans sa **config Claude Code locale**.
**Méthode recommandée (identique sur Fedora, Windows et macOS) :**
```bash
claude mcp add --transport http --scope user lesstime \
http://project.malio-dev.fr/_mcp \
--header "Authorization: Bearer <api-token>"
```
- En prod : `http://project.malio-dev.fr/_mcp`
- En réseau local : `http://<ip-serveur>:8082/_mcp`
**Où c'est stocké** (si tu édites le fichier à la main, sous la clé `mcpServers`) :
| OS | Fichier de config Claude Code |
|----|-------------------------------|
| **Fedora / Linux** | `~/.claude.json` |
| **Windows** (collègue) | `%USERPROFILE%\.claude.json` (ex. `C:\Users\<user>\.claude.json`) |
| **macOS** | `~/.claude.json` |
```json ```json
{ {
"mcpServers": { "mcpServers": {
"lesstime": { "lesstime": {
"type": "url", "type": "http",
"url": "http://<ip-serveur>:8082/_mcp", "url": "http://project.malio-dev.fr/_mcp",
"headers": { "headers": { "Authorization": "Bearer <api-token>" }
"Authorization": "Bearer <api-token>"
}
} }
} }
} }
``` ```
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
### Gestion des tokens API ### Gestion des tokens API
Générer / régénérer un token pour un utilisateur :
```bash ```bash
# En dev (container local)
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username> docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
# En prod (sur le serveur, dans infra/prod)
sudo docker compose exec -T -u www-data app php bin/console app:generate-api-token <username>
``` ```
⚠️ Le token est **invalidé à chaque reset/reseed de la base**. Symptôme : `/mcp` renvoie
`HTTP 401 "Invalid API token"`. Il faut alors le **régénérer** (commande ci-dessus) puis remplacer
la valeur `Bearer ...` dans ta config locale (par poste).
## Déploiement ## Déploiement
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*` La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
@@ -270,7 +302,7 @@ INFRA #146.
|---|---|---| |---|---|---|
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) | | Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) | | Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI | | Où mettre le DSN | `.env` du serveur (`/var/www/lesstime/.env`) — runtime | **secrets Gitea** → build-args de la CI |
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire* > Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui > le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
@@ -278,7 +310,7 @@ INFRA #146.
### Variables ### Variables
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) : **Backend — fichier `.env` du serveur** (`/var/www/lesstime/.env`, chargé via `env_file` ; le repo ne fournit que le template `infra/prod/.env.example`) :
```env ```env
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api> SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
``` ```
@@ -312,7 +344,7 @@ SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN 1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
(+ un auth token pour les source maps). (+ un auth token pour les source maps).
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur. 2. Backend : ajouter `SENTRY_DSN` dans le `.env` du serveur (`/var/www/lesstime/.env`).
3. Frontend : ajouter les secrets Gitea ci-dessus. 3. Frontend : ajouter les secrets Gitea ci-dessus.
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`. 4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
@@ -9,11 +9,15 @@ lexik_jwt_authentication:
enabled: false enabled: false
cookie: cookie:
enabled: true enabled: true
name: BEARER # Cookie nommé par app (BEARER_LESSTIME) pour éviter la collision avec
# d'autres apps Symfony servies sur le même domaine localhost en dev
# (ex: Starseed reste sur BEARER) : un cookie `BEARER` partagé se ferait
# écraser d'une app à l'autre → déconnexions croisées.
name: BEARER_LESSTIME
query_parameter: query_parameter:
enabled: false enabled: false
set_cookies: set_cookies:
BEARER: BEARER_LESSTIME:
lifetime: '%env(int:JWT_COOKIE_TTL)%' lifetime: '%env(int:JWT_COOKIE_TTL)%'
samesite: lax samesite: lax
path: / path: /
+3 -1
View File
@@ -22,6 +22,7 @@ security:
pattern: ^/login_check pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
login_throttling: login_throttling:
max_attempts: 5 max_attempts: 5
interval: '1 minute' interval: '1 minute'
@@ -41,13 +42,14 @@ security:
pattern: ^/api pattern: ^/api
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
jwt: ~ jwt: ~
logout: logout:
path: /api/logout path: /api/logout
target: /login target: /login
enable_csrf: false enable_csrf: false
delete_cookies: delete_cookies:
BEARER: BEARER_LESSTIME:
path: / path: /
# Activate different ways to authenticate: # Activate different ways to authenticate:
+4
View File
@@ -125,6 +125,10 @@ services:
tags: tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist } - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor: App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.43' app.version: '0.4.48'
+82 -6
View File
@@ -11,15 +11,33 @@
/> />
</div> </div>
<div class="mt-4">
<MalioCheckbox
v-model="showArchived"
:label="$t('users.showArchived')"
:reserve-message-space="false"
/>
</div>
<DataTable <DataTable
:columns="columns" :columns="columns"
:items="items" :items="items"
:loading="isLoading" :loading="isLoading"
empty-message="Aucun utilisateur trouvé." empty-message="Aucun utilisateur trouvé."
deletable
@row-click="openEdit" @row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
> >
<template #cell-username="{ item }">
<span :class="{ 'text-neutral-400 line-through': item.archived }">
{{ item.username }}
</span>
<span
v-if="item.archived"
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
>
{{ $t('users.archivedBadge') }}
</span>
</template>
<template #cell-roles="{ item }"> <template #cell-roles="{ item }">
<span <span
v-for="role in item.roles" v-for="role in item.roles"
@@ -29,6 +47,27 @@
{{ role }} {{ role }}
</span> </span>
</template> </template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="item.archived"
icon="mdi:restore"
:aria-label="$t('users.restore')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-primary-500"
@click.stop="handleRestore(item)"
/>
<MalioButtonIcon
v-else-if="item.id !== currentUserId"
icon="mdi:delete-outline"
:aria-label="$t('users.archive')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="openArchiveConfirm(item)"
/>
</template>
</DataTable> </DataTable>
<UserDrawer <UserDrawer
@@ -36,12 +75,19 @@
:item="selectedItem" :item="selectedItem"
@saved="onSaved" @saved="onSaved"
/> />
<ConfirmArchiveUserModal
v-model="archiveConfirmOpen"
:username="userToArchive?.username ?? ''"
@confirm="confirmArchive"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
{ key: 'roles', label: 'Rôles' }, { key: 'roles', label: 'Rôles' },
] ]
const { getAll, remove } = useUserService() const { getAll, getArchived, remove, restore } = useUserService()
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.user?.id)
const items = ref<UserData[]>([]) const items = ref<UserData[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const selectedItem = ref<UserData | null>(null) const selectedItem = ref<UserData | null>(null)
const showArchived = ref(false)
const archiveConfirmOpen = ref(false)
const userToArchive = ref<UserData | null>(null)
async function loadItems() { async function loadItems() {
isLoading.value = true isLoading.value = true
try { try {
items.value = await getAll() if (showArchived.value) {
const [active, archived] = await Promise.all([getAll(), getArchived()])
items.value = [...active, ...archived]
} else {
items.value = await getAll()
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
drawerOpen.value = true drawerOpen.value = true
} }
async function handleDelete(id: number) { function openArchiveConfirm(item: UserData) {
await remove(id) userToArchive.value = item
archiveConfirmOpen.value = true
}
async function confirmArchive() {
if (!userToArchive.value) {
return
}
await remove(userToArchive.value.id)
archiveConfirmOpen.value = false
userToArchive.value = null
await loadItems()
}
async function handleRestore(item: UserData) {
await restore(item.id)
await loadItems() await loadItems()
} }
@@ -84,6 +156,10 @@ async function onSaved() {
await loadItems() await loadItems()
} }
watch(showArchived, () => {
loadItems()
})
onMounted(() => { onMounted(() => {
loadItems() loadItems()
}) })
@@ -1,61 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('directory.reports.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
:disabled="busy"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="busy"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
// Suppression en cours : on désactive les actions pour éviter un double envoi.
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
if (props.busy) return
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
@@ -4,20 +4,20 @@
<div class="fixed inset-0 z-50 flex items-center justify-center"> <div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" /> <div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600"> <p class="mt-3 text-sm text-neutral-600">
{{ message }} {{ $t('users.archiveConfirmMessage', { username }) }}
</p> </p>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
:label="$t('common.cancel')" label="Annuler"
button-class="w-auto px-4" button-class="w-auto px-4"
@click="cancel" @click="cancel"
/> />
<MalioButton <MalioButton
variant="danger" variant="danger"
:label="$t('common.delete')" :label="$t('users.archive')"
button-class="w-auto px-4" button-class="w-auto px-4"
@click="$emit('confirm')" @click="$emit('confirm')"
/> />
@@ -31,8 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
title: string username: string
message: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+22 -3
View File
@@ -194,8 +194,16 @@
"created": "Utilisateur créé avec succès.", "created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.", "updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.", "deleted": "Utilisateur supprimé avec succès.",
"archived": "Utilisateur archivé avec succès.",
"restored": "Utilisateur restauré avec succès.",
"addUser": "Ajouter un utilisateur", "addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur" "editUser": "Modifier un utilisateur",
"archivedBadge": "Archivé",
"showArchived": "Afficher les utilisateurs archivés",
"archive": "Archiver",
"restore": "Restaurer",
"archiveConfirmTitle": "Archiver l'utilisateur",
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -424,6 +432,7 @@
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
"add": "Ajouter", "add": "Ajouter",
"actions": "Action",
"loading": "Chargement...", "loading": "Chargement...",
"archived": "Archivé", "archived": "Archivé",
"noClient": "Aucun client", "noClient": "Aucun client",
@@ -918,6 +927,9 @@
"editProspect": "Modifier un prospect", "editProspect": "Modifier un prospect",
"convert": "Convertir en client", "convert": "Convertir en client",
"alreadyConverted": "Déjà converti en client", "alreadyConverted": "Déjà converti en client",
"convertConfirmTitle": "Convertir le prospect",
"convertConfirmMessage": "Êtes-vous sûr de vouloir convertir le prospect « {name} » en client ? Le prospect deviendra un client.",
"convertConfirm": "Convertir",
"deleteConfirmTitle": "Supprimer le prospect", "deleteConfirmTitle": "Supprimer le prospect",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.", "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": { "fields": {
@@ -1000,10 +1012,12 @@
"empty": "Aucun prestataire trouvé." "empty": "Aucun prestataire trouvé."
}, },
"contacts": { "contacts": {
"add": "Ajouter un contact", "add": "Nouveau contact",
"item": "Contact {n}", "item": "Contact {n}",
"saved": "Contact enregistré.", "saved": "Contact enregistré.",
"deleted": "Contact supprimé.", "deleted": "Contact supprimé.",
"deleteConfirmTitle": "Supprimer le contact",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce contact ? Cette action est irréversible.",
"fields": { "fields": {
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
@@ -1014,11 +1028,14 @@
} }
}, },
"addresses": { "addresses": {
"add": "Ajouter une adresse", "add": "Nouvelle adresse",
"item": "Adresse {n}", "item": "Adresse {n}",
"saved": "Adresse enregistrée.", "saved": "Adresse enregistrée.",
"deleted": "Adresse supprimée.", "deleted": "Adresse supprimée.",
"deleteConfirmTitle": "Supprimer l'adresse",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer cette adresse ? Cette action est irréversible.",
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.", "streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
"streetHint": "Renseignez d'abord le code postal et la ville.",
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.", "autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": { "fields": {
"label": "Libellé", "label": "Libellé",
@@ -1040,6 +1057,8 @@
"deleted": "Compte-rendu supprimé.", "deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?", "confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.", "confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
"documentDeleteTitle": "Supprimer le document",
"documentDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.",
"fields": { "fields": {
"subject": "Objet", "subject": "Objet",
"type": "Type d'échange", "type": "Type d'échange",
@@ -9,8 +9,7 @@
v-if="canManage" v-if="canManage"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.reports.add')"
@click="openCreate" @click="openCreate"
/> />
</div> </div>
@@ -108,7 +107,7 @@
v-if="report.documents?.length" v-if="report.documents?.length"
:documents="report.documents" :documents="report.documents"
:can-manage="canManage" :can-manage="canManage"
@delete="(docId) => removeDocument(docId)" @delete="(docId) => askDeleteDocument(docId)"
/> />
<ReportDocumentUpload <ReportDocumentUpload
v-if="canManage" v-if="canManage"
@@ -127,11 +126,18 @@
:owner="owner" :owner="owner"
@saved="reload" @saved="reload"
/> />
<ConfirmDeleteReportModal <ConfirmModal
v-model="confirmOpen" v-model="confirmOpen"
:busy="deleting" :title="$t('directory.reports.confirmDeleteTitle')"
:message="$t('directory.reports.confirmDeleteMessage')"
@confirm="confirmDelete" @confirm="confirmDelete"
/> />
<ConfirmModal
v-model="docConfirmOpen"
:title="$t('directory.reports.documentDeleteTitle')"
:message="$t('directory.reports.documentDeleteMessage')"
@confirm="confirmDeleteDocument"
/>
</div> </div>
</template> </template>
@@ -158,6 +164,11 @@ const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null) const pendingDelete = ref<CommercialReport | null>(null)
const deleting = ref(false) const deleting = ref(false)
// Suppression d'un document joint : passe désormais par une modal de confirmation.
const docConfirmOpen = ref(false)
const pendingDocId = ref<number | null>(null)
const deletingDoc = ref(false)
// Le plus récent en haut (l'API ne garantit pas l'ordre). // Le plus récent en haut (l'API ne garantit pas l'ordre).
const sortedReports = computed(() => const sortedReports = computed(() =>
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), [...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
@@ -222,9 +233,22 @@ async function confirmDelete(): Promise<void> {
} }
} }
async function removeDocument(id: number): Promise<void> { function askDeleteDocument(id: number): void {
await documentService.remove(id) pendingDocId.value = id
await reload() docConfirmOpen.value = true
}
async function confirmDeleteDocument(): Promise<void> {
if (pendingDocId.value === null || deletingDoc.value) return
deletingDoc.value = true
try {
await documentService.remove(pendingDocId.value)
docConfirmOpen.value = false
pendingDocId.value = null
await reload()
} finally {
deletingDoc.value = false
}
} }
async function reload(): Promise<void> { async function reload(): Promise<void> {
@@ -0,0 +1,54 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">{{ title }}</h2>
</template>
<!-- Corps : slot par défaut pour permettre du texte enrichi (nom en gras
via <i18n-t>) ; sinon repli sur le message texte simple. -->
<slot>
<p>{{ message }}</p>
</slot>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="cancelLabel ?? $t('common.cancel')"
@click="$emit('update:modelValue', false)"
/>
<MalioButton
:variant="confirmVariant"
button-class="flex-1"
:label="confirmLabel ?? $t('common.delete')"
@click="$emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
modelValue: boolean
title: string
message?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
}>(),
{
message: undefined,
confirmLabel: undefined,
cancelLabel: undefined,
confirmVariant: 'danger',
},
)
defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
</script>
@@ -1,84 +1,93 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> (pas de bordure sous le dernier bloc), comme sur Starseed. -->
{{ title }} <div class="pb-5" :class="{ 'border-b border-black': !last }">
</h3> <div class="flex items-center justify-between">
<MalioButtonIcon <!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
v-if="removable && !readonly" <h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
icon="mdi:delete-outline" <MalioButtonIcon
variant="ghost" v-if="removable && !readonly"
class="absolute right-3 top-3" icon="mdi:delete-outline"
:aria-label="$t('common.delete')" variant="ghost"
@click="$emit('remove')" button-class="p-0"
/> :aria-label="$t('common.delete')"
@click="$emit('remove')"
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
allow-create conserve le texte saisi si la BAN ne propose rien
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/> />
</div> </div>
<MalioInputText <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
class="col-span-2" <MalioInputText
:label="$t('directory.addresses.fields.streetComplement')" class="col-span-2"
:model-value="modelValue.streetComplement ?? ''" :label="$t('directory.addresses.fields.label')"
:readonly="readonly" :model-value="modelValue.label ?? ''"
@update:model-value="update('streetComplement', $event)" :readonly="readonly"
/> @update:model-value="update('label', $event)"
/>
<MalioInputText <!-- On commence par le code postal : il alimente la liste des villes (BAN)
:label="$t('directory.addresses.fields.postalCode')" et réinitialise ville/rue devenues incohérentes en cas de changement. -->
:model-value="modelValue.postalCode ?? ''" <MalioInputText
:readonly="readonly" :label="$t('directory.addresses.fields.postalCode')"
@update:model-value="onPostalCodeInput" :model-value="modelValue.postalCode ?? ''"
/> :readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé <!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. --> (BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect <MalioSelect
v-if="!readonly && !degraded" v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''" :model-value="modelValue.city ?? ''"
:options="cityOptions" :options="cityOptions"
:label="$t('directory.addresses.fields.city')" :label="$t('directory.addresses.fields.city')"
empty-option-label="" empty-option-label=""
group-class="w-full" group-class="w-full"
@update:model-value="(v) => update('city', v === null ? '' : String(v))" @update:model-value="onCityChange"
/> />
<MalioInputText <MalioInputText
v-else v-else
:label="$t('directory.addresses.fields.city')" :label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''" :model-value="modelValue.city ?? ''"
:readonly="readonly" :readonly="readonly"
@update:model-value="update('city', $event)" @update:model-value="update('city', $event)"
/> />
<!-- Rue : conditionnée au code postal + ville (comme Starseed). Saisie
assistée (BAN) filtrée par le code postal ; désactivée tant que CP et
ville ne sont pas renseignés. Champ texte simple en lecture seule. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:disabled="!canEditStreet"
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
</div>
</div> </div>
</template> </template>
@@ -94,6 +103,8 @@ const props = defineProps<{
title: string title: string
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -112,6 +123,16 @@ const addressOptions = ref<Option[]>([])
const fetchedCityOptions = ref<Option[]>([]) const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false) const addressLoading = ref(false)
// Titre du bloc : le libellé saisi prime ; repli sur « Adresse N » (prop `title`).
const blockTitle = computed(() => (props.modelValue.label ?? '').trim() || props.title)
// La rue n'est éditable qu'une fois le code postal (5 chiffres) ET la ville
// renseignés — conditionnement métier repris de Starseed.
const canEditStreet = computed(() => {
const digits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
return digits.length === 5 && !!(props.modelValue.city ?? '').trim()
})
// Le select Ville n'affiche que les valeurs présentes dans ses options : on // Le select Ville n'affiche que les valeurs présentes dans ses options : on
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou // garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste, // pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
@@ -140,6 +161,23 @@ function notifyUnavailable(): void {
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') }) toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
} }
/**
* Sélection d'une ville → vide rue + complément (devenus incohérents avec la
* nouvelle ville). Ne réagit qu'à un vrai changement de valeur.
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? '' : String(value)
if (next === (props.modelValue.city ?? '')) return
addressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next === '' ? null : next,
street: null,
streetComplement: null,
})
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */ /** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> { async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) { if (query.trim().length < 3) {
@@ -180,10 +218,30 @@ function onAddressSelect(option: Option | null): void {
}) })
} }
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */ /**
* Saisie du code postal → réinitialise ville/rue/complément quand le CP est
* complet (5 chiffres) ET réellement modifié, puis interroge la BAN pour les
* villes. Sinon simple mise à jour du champ (correction partielle).
*/
async function onPostalCodeInput(value: string): Promise<void> { async function onPostalCodeInput(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
if (digits.length === 5 && digits !== previousDigits) {
addressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) return if (digits.length < 5) return
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
@@ -1,57 +1,61 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> (pas de bordure sous le dernier bloc), comme sur Starseed. -->
{{ title }} <div class="pb-5" :class="{ 'border-b border-black': !last }">
</h3> <div class="flex items-center justify-between">
<MalioButtonIcon <h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
v-if="removable && !readonly" <MalioButtonIcon
icon="mdi:delete-outline" v-if="removable && !readonly"
variant="ghost" icon="mdi:delete-outline"
class="absolute right-3 top-3" variant="ghost"
:aria-label="$t('common.delete')" button-class="p-0"
@click="$emit('remove')" :aria-label="$t('common.delete')"
/> @click="$emit('remove')"
/>
</div>
<MalioInputText <div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
:label="$t('directory.contacts.fields.lastName')" <MalioInputText
:model-value="modelValue.lastName ?? ''" :label="$t('directory.contacts.fields.lastName')"
:readonly="readonly" :model-value="modelValue.lastName ?? ''"
@update:model-value="update('lastName', $event)" :readonly="readonly"
/> @update:model-value="update('lastName', $event)"
<MalioInputText />
:label="$t('directory.contacts.fields.firstName')" <MalioInputText
:model-value="modelValue.firstName ?? ''" :label="$t('directory.contacts.fields.firstName')"
:readonly="readonly" :model-value="modelValue.firstName ?? ''"
@update:model-value="update('firstName', $event)" :readonly="readonly"
/> @update:model-value="update('firstName', $event)"
<MalioInputText />
class="col-span-2" <MalioInputText
:label="$t('directory.contacts.fields.jobTitle')" class="col-span-2"
:model-value="modelValue.jobTitle ?? ''" :label="$t('directory.contacts.fields.jobTitle')"
:readonly="readonly" :model-value="modelValue.jobTitle ?? ''"
@update:model-value="update('jobTitle', $event)" :readonly="readonly"
/> @update:model-value="update('jobTitle', $event)"
<MalioInputText />
:label="$t('directory.contacts.fields.email')" <MalioInputEmail
:model-value="modelValue.email ?? ''" :label="$t('directory.contacts.fields.email')"
:readonly="readonly" :model-value="modelValue.email ?? ''"
:error="emailError" :readonly="readonly"
@update:model-value="update('email', $event)" :error="emailError"
/> @update:model-value="update('email', $event)"
<MalioInputText />
:label="$t('directory.contacts.fields.phonePrimary')" <MalioInputPhone
:model-value="modelValue.phonePrimary ?? ''" :label="$t('directory.contacts.fields.phonePrimary')"
:readonly="readonly" :model-value="modelValue.phonePrimary ?? ''"
:error="phonePrimaryError" :readonly="readonly"
@update:model-value="update('phonePrimary', $event)" :error="phonePrimaryError"
/> @update:model-value="update('phonePrimary', $event)"
<MalioInputText />
:label="$t('directory.contacts.fields.phoneSecondary')" <MalioInputPhone
:model-value="modelValue.phoneSecondary ?? ''" :label="$t('directory.contacts.fields.phoneSecondary')"
:readonly="readonly" :model-value="modelValue.phoneSecondary ?? ''"
:error="phoneSecondaryError" :readonly="readonly"
@update:model-value="update('phoneSecondary', $event)" :error="phoneSecondaryError"
/> @update:model-value="update('phoneSecondary', $event)"
/>
</div>
</div> </div>
</template> </template>
@@ -64,6 +68,8 @@ const props = defineProps<{
title: string title: string
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,8 +16,8 @@
</a> </a>
<MalioButtonIcon <MalioButtonIcon
v-if="canManage" v-if="canManage"
icon="mdi:trash-can-outline" icon="mdi:delete-outline"
button-class="!text-red-600" variant="ghost"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)" @click="$emit('delete', doc.id)"
/> />
@@ -1,20 +1,14 @@
<template> <template>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <MalioInputUpload
ref="fileInput" v-model="fileName"
type="file" class="flex-1"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.documents.add')" :label="$t('directory.documents.add')"
:disabled="uploading" :disabled="uploading"
@click="fileInput?.click()" :reserve-message-space="false"
@file-selected="onFile"
/> />
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span> <span v-if="uploading" class="shrink-0 text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
</div> </div>
</template> </template>
@@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>() const emit = defineEmits<{ uploaded: [] }>()
const service = useReportDocumentService() const service = useReportDocumentService()
const fileInput = ref<HTMLInputElement | null>(null) // Nom du fichier affiché par le champ Malio (v-model) ; réinitialisé après envoi.
const fileName = ref('')
const uploading = ref(false) const uploading = ref(false)
async function onFileSelected(event: Event): Promise<void> { // L'upload se déclenche dès la sélection (event natif du composant Malio).
const input = event.target as HTMLInputElement async function onFile(file: File): Promise<void> {
const file = input.files?.[0]
if (!file) return
uploading.value = true uploading.value = true
try { try {
await service.upload(props.reportId, file) await service.upload(props.reportId, file)
emit('uploaded') emit('uploaded')
} finally { } finally {
uploading.value = false uploading.value = false
input.value = '' fileName.value = ''
} }
} }
</script> </script>
@@ -14,6 +14,7 @@ type Owner = { client?: string, prospect?: string, prestataire?: string }
* tel quel par les deux pages. * tel quel par les deux pages.
*/ */
export function useDirectoryDetail(owner: Owner) { export function useDirectoryDetail(owner: Owner) {
const { t } = useI18n()
const contactService = useContactService() const contactService = useContactService()
const addressService = useAddressService() const addressService = useAddressService()
@@ -59,6 +60,39 @@ export function useDirectoryDetail(owner: Owner) {
addresses.value.splice(index, 1) addresses.value.splice(index, 1)
} }
// Confirmation de suppression d'un bloc (contact / adresse) : la corbeille du
// bloc ouvre une modal ; la suppression effective n'a lieu qu'à la confirmation.
const removeModalOpen = ref(false)
const pendingRemoval = ref<{ type: 'contact' | 'address', index: number } | null>(null)
const removeModalTitle = computed(() =>
pendingRemoval.value?.type === 'address'
? t('directory.addresses.deleteConfirmTitle')
: t('directory.contacts.deleteConfirmTitle'),
)
const removeModalMessage = computed(() =>
pendingRemoval.value?.type === 'address'
? t('directory.addresses.deleteConfirmMessage')
: t('directory.contacts.deleteConfirmMessage'),
)
function askRemoveContact(index: number): void {
pendingRemoval.value = { type: 'contact', index }
removeModalOpen.value = true
}
function askRemoveAddress(index: number): void {
pendingRemoval.value = { type: 'address', index }
removeModalOpen.value = true
}
async function confirmRemove(): Promise<void> {
const p = pendingRemoval.value
if (!p) return
if (p.type === 'contact') await removeContact(p.index)
else await removeAddress(p.index)
removeModalOpen.value = false
pendingRemoval.value = null
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux // Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées. // blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> { async function saveContacts(): Promise<void> {
@@ -117,5 +151,12 @@ export function useDirectoryDetail(owner: Owner) {
removeAddress, removeAddress,
saveAddresses, saveAddresses,
load, load,
// Suppression de bloc avec confirmation (modal partagée contact/adresse).
removeModalOpen,
removeModalTitle,
removeModalMessage,
askRemoveContact,
askRemoveAddress,
confirmRemove,
} }
} }
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ client?.name ?? '…' }} {{ client?.name ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.name" v-model="info.name"
class="col-span-2" class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''" :error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true" @blur="infoTouched.name = true"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('directory.info.fields.email')" :label="$t('directory.info.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('directory.info.fields.phone')" :label="$t('directory.info.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -40,7 +47,6 @@
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -141,13 +154,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -192,7 +209,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { function goBack(): void {
router.push('/directory') // Retour sur l'onglet Clients de la liste (via history.state, hors URL).
router.push({ path: '/directory', state: { tab: 'clients' } })
} }
onMounted(async () => { onMounted(async () => {
@@ -9,12 +9,11 @@
<!-- Clients --> <!-- Clients -->
<template #clients> <template #clients>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end"> <div class="flex min-h-[48px] items-center justify-end">
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.clients.add')"
@click="openCreateClient" @click="openCreateClient"
/> />
</div> </div>
@@ -26,6 +25,9 @@
:empty-message="$t('directory.clients.empty')" :empty-message="$t('directory.clients.empty')"
@row-click="openEditClient" @row-click="openEditClient"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }"> <template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }} {{ (item as Client).email ?? '—' }}
</template> </template>
@@ -37,7 +39,7 @@
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeleteClient(item as Client)" @click="askDeleteClient(item as Client)"
/> />
@@ -50,7 +52,7 @@
<!-- Prospects --> <!-- Prospects -->
<template #prospects> <template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap items-end justify-between gap-3"> <div class="flex min-h-[48px] flex-wrap items-center justify-between gap-3">
<MalioSelect <MalioSelect
v-model="statusFilter" v-model="statusFilter"
:label="$t('prospects.fields.status')" :label="$t('prospects.fields.status')"
@@ -61,8 +63,7 @@
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.prospects.add')"
@click="openCreateProspect" @click="openCreateProspect"
/> />
</div> </div>
@@ -74,6 +75,9 @@
:empty-message="$t('directory.prospects.empty')" :empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect" @row-click="openEditProspect"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-status="{ item }"> <template #cell-status="{ item }">
<StatusBadge <StatusBadge
:label="statusLabel((item as ProspectRow).status)" :label="statusLabel((item as ProspectRow).status)"
@@ -92,14 +96,14 @@
v-if="!(item as ProspectRow).convertedClient" v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert" icon="mdi:account-convert"
:aria-label="$t('prospects.convert')" :aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700" button-class="!bg-green-100 !text-green-700 hover:!bg-green-200"
:icon-size="18" :icon-size="18"
@click="convertProspect(item as ProspectRow)" @click="askConvertProspect(item as ProspectRow)"
/> />
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeleteProspect(item as ProspectRow)" @click="askDeleteProspect(item as ProspectRow)"
/> />
@@ -111,12 +115,11 @@
<!-- Prestataires --> <!-- Prestataires -->
<template #prestataires> <template #prestataires>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end"> <div class="flex min-h-[48px] items-center justify-end">
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.prestataires.add')"
@click="openCreatePrestataire" @click="openCreatePrestataire"
/> />
</div> </div>
@@ -128,6 +131,9 @@
:empty-message="$t('directory.prestataires.empty')" :empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire" @row-click="openEditPrestataire"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }"> <template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }} {{ (item as Prestataire).email ?? '—' }}
</template> </template>
@@ -139,7 +145,7 @@
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeletePrestataire(item as Prestataire)" @click="askDeletePrestataire(item as Prestataire)"
/> />
@@ -166,12 +172,31 @@
@saved="loadPrestataires" @saved="loadPrestataires"
/> />
<ConfirmDeleteModal <ConfirmModal
v-model="deleteModalOpen" v-model="deleteModalOpen"
:title="deleteModalTitle" :title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete" @confirm="confirmDelete"
/> >
<i18n-t :keypath="deleteModalKeypath" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ deleteTargetName }}</strong>
</template>
</i18n-t>
</ConfirmModal>
<ConfirmModal
v-model="convertModalOpen"
:title="$t('prospects.convertConfirmTitle')"
:confirm-label="$t('prospects.convertConfirm')"
confirm-variant="primary"
@confirm="confirmConvert"
>
<i18n-t keypath="prospects.convertConfirmMessage" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ convertTarget?.company }}</strong>
</template>
</i18n-t>
</ConfirmModal>
</div> </div>
</div> </div>
</template> </template>
@@ -183,6 +208,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire' import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires' import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -201,6 +227,14 @@ const tabs = [
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' }, { key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' }, { key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
] ]
const tabKeys = tabs.map((tab) => tab.key)
// Avant d'ouvrir une fiche : on estampille l'entrée d'historique courante avec
// l'onglet actif → la flèche « précédent » du navigateur restaure le bon onglet.
function navigateToDetail(path: string): void {
stampHistoryTab(activeTab.value)
navigateTo(path)
}
// --- Clients --- // --- Clients ---
const clients = ref<Client[]>([]) const clients = ref<Client[]>([])
@@ -211,7 +245,7 @@ const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') }, { key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
async function loadClients() { async function loadClients() {
@@ -224,7 +258,7 @@ function openCreateClient() {
} }
function openEditClient(item: Record<string, unknown>) { function openEditClient(item: Record<string, unknown>) {
navigateTo(`/directory/clients/${(item as Client).id}`) navigateToDetail(`/directory/clients/${(item as Client).id}`)
} }
// --- Prospects --- // --- Prospects ---
@@ -246,7 +280,7 @@ const prospectColumns = [
{ key: 'status', label: t('prospects.fields.status') }, { key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
const prospectRows = computed<ProspectRow[]>(() => prospects.value) const prospectRows = computed<ProspectRow[]>(() => prospects.value)
@@ -282,13 +316,26 @@ function openCreateProspect() {
} }
function openEditProspect(item: Record<string, unknown>) { function openEditProspect(item: Record<string, unknown>) {
navigateTo(`/directory/prospects/${(item as Prospect).id}`) navigateToDetail(`/directory/prospects/${(item as Prospect).id}`)
} }
async function convertProspect(row: ProspectRow) { // La conversion passe par une modal de confirmation (le prospect devient client).
const convertModalOpen = ref(false)
const convertTarget = ref<ProspectRow | null>(null)
function askConvertProspect(row: ProspectRow) {
convertTarget.value = row
convertModalOpen.value = true
}
async function confirmConvert() {
const row = convertTarget.value
if (!row) return
await prospectService.convert(row.id) await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes. // La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()]) await Promise.all([loadProspects(), loadClients()])
convertModalOpen.value = false
convertTarget.value = null
} }
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut // Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
@@ -306,7 +353,7 @@ const prestataireColumns = [
{ key: 'name', label: t('prospects.fields.company') }, { key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
async function loadPrestataires() { async function loadPrestataires() {
@@ -319,7 +366,7 @@ function openCreatePrestataire() {
} }
function openEditPrestataire(item: Record<string, unknown>) { function openEditPrestataire(item: Record<string, unknown>) {
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`) navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
} }
// --- Suppression (clients, prospects & prestataires) --- // --- Suppression (clients, prospects & prestataires) ---
@@ -342,17 +389,22 @@ const deleteModalTitle = computed(() => {
} }
}) })
const deleteModalMessage = computed(() => { // Clé i18n du message (le nom y est injecté en gras via <i18n-t> côté template).
const deleteModalKeypath = computed(() => {
switch (deleteTarget.value?.type) {
case 'prospect':
return 'prospects.deleteConfirmMessage'
case 'prestataire':
return 'prestataires.deleteConfirmMessage'
default:
return 'clients.deleteConfirmMessage'
}
})
const deleteTargetName = computed(() => {
const target = deleteTarget.value const target = deleteTarget.value
if (!target) return '' if (!target) return ''
switch (target.type) { return target.type === 'prospect' ? target.item.company : target.item.name
case 'prospect':
return t('prospects.deleteConfirmMessage', { name: target.item.company })
case 'prestataire':
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
default:
return t('clients.deleteConfirmMessage', { name: target.item.name })
}
}) })
function askDeleteClient(item: Client) { function askDeleteClient(item: Client) {
@@ -392,6 +444,9 @@ async function confirmDelete() {
watch(statusFilter, loadProspects) watch(statusFilter, loadProspects)
onMounted(async () => { onMounted(async () => {
// Restaure l'onglet quitté lors d'un retour depuis une fiche (flèche app ou
// navigateur). `null` (deep link / reload) → onglet Clients par défaut.
activeTab.value = readHistoryTab(tabKeys) ?? 'clients'
await Promise.all([loadClients(), loadProspects(), loadPrestataires()]) await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
}) })
</script> </script>
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ prestataire?.name ?? '…' }} {{ prestataire?.name ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.name" v-model="info.name"
class="col-span-2" class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''" :error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true" @blur="infoTouched.name = true"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('directory.info.fields.email')" :label="$t('directory.info.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('directory.info.fields.phone')" :label="$t('directory.info.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -40,7 +47,6 @@
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -141,13 +154,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -190,7 +207,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { function goBack(): void {
router.push('/directory') // Retour sur l'onglet Prestataires de la liste (via history.state, hors URL).
router.push({ path: '/directory', state: { tab: 'prestataires' } })
} }
onMounted(async () => { onMounted(async () => {
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ prospect?.company ?? '…' }} {{ prospect?.company ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.company" v-model="info.company"
class="col-span-2" class="col-span-2"
@@ -32,12 +39,12 @@
:label="$t('prospects.fields.website')" :label="$t('prospects.fields.website')"
:error="websiteError" :error="websiteError"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('prospects.fields.email')" :label="$t('prospects.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('prospects.fields.phone')" :label="$t('prospects.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -47,15 +54,21 @@
class="col-span-2" class="col-span-2"
:label="$t('prospects.fields.source')" :label="$t('prospects.fields.source')"
/> />
<!-- Notes : 2 colonnes, hauteur fixe (~2 lignes) avec scroll
interne. Pas de row-span (il déréglait l'auto-placement).
!max-w-none : neutralise le max-width:640px inline du
composant Malio (sinon la textarea ne remplit pas 2 colonnes). -->
<MalioInputTextArea <MalioInputTextArea
v-model="info.notes" v-model="info.notes"
class="col-span-2" group-class="col-span-2"
text-input="!h-28 !max-w-none text-lg"
resize="none"
:reserve-message-space="false"
:label="$t('prospects.fields.notes')" :label="$t('prospects.fields.notes')"
/> />
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -72,12 +85,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -85,7 +99,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -102,12 +115,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -115,7 +129,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -130,6 +143,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -156,13 +176,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -226,7 +250,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { function goBack(): void {
router.push('/directory') // Retour sur l'onglet Prospects de la liste (via history.state, hors URL).
router.push({ path: '/directory', state: { tab: 'prospects' } })
} }
onMounted(async () => { onMounted(async () => {
@@ -32,6 +32,13 @@
empty-option-label="Aucun client" empty-option-label="Aucun client"
group-class="w-full" group-class="w-full"
/> />
<MalioSelect
v-if="!isEditing"
v-model="form.workflowId"
:options="workflowOptions"
label="Workflow"
group-class="w-full"
/>
<div class="mt-4"> <div class="mt-4">
<ColorPicker v-model="form.color" /> <ColorPicker v-model="form.color" />
</div> </div>
@@ -124,10 +131,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project' import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea' import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack' import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
bookstackShelves.value.map(s => ({ label: s.name, value: s.id })) bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
) )
const { getAll: getAllWorkflows } = useWorkflowService()
const workflows = ref<Workflow[]>([])
const workflowOptions = computed(() =>
workflows.value.map(w => ({ label: w.name, value: w.id }))
)
function defaultWorkflowId(): number | null {
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
}
const form = reactive({ const form = reactive({
code: '', code: '',
name: '', name: '',
description: '', description: '',
color: '#222783', color: '#222783',
clientId: null as number | null, clientId: null as number | null,
workflowId: null as number | null,
giteaRepoFullName: null as string | null, giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null, bookstackShelfId: null as number | null,
}) })
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
form.description = '' form.description = ''
form.color = '#222783' form.color = '#222783'
form.clientId = null form.clientId = null
form.workflowId = defaultWorkflowId()
form.giteaRepoFullName = null form.giteaRepoFullName = null
form.bookstackShelfId = null form.bookstackShelfId = null
} }
@@ -269,6 +291,9 @@ async function handleSubmit() {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
payload.code = form.code payload.code = form.code
if (form.workflowId) {
payload.workflow = `/api/workflows/${form.workflowId}`
}
await create(payload) await create(payload)
} }
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
} }
onMounted(async () => { onMounted(async () => {
try {
workflows.value = await getAllWorkflows()
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
if (props.modelValue && !props.project && !form.workflowId) {
form.workflowId = defaultWorkflowId()
}
} catch {
// Workflows indisponibles, ignore (le serveur assignera le défaut)
}
try { try {
giteaRepos.value = await listRepositories() giteaRepos.value = await listRepositories()
} catch { } catch {
+2
View File
@@ -10,6 +10,8 @@ export type UserData = {
effectivePermissions?: string[] effectivePermissions?: string[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null apiToken?: string | null
// Soft-delete flag: an archived user keeps its data but cannot log in
archived?: boolean
// HR / absence management // HR / absence management
isEmployee?: boolean isEmployee?: boolean
hireDate?: string | null hireDate?: string | null
+17 -2
View File
@@ -10,6 +10,13 @@ export function useUserService() {
return extractHydraMembers(data) return extractHydraMembers(data)
} }
// Archived users are hidden from the default collection; an admin lists
// them explicitly via the `archived` filter (handled server-side).
async function getArchived(): Promise<UserData[]> {
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<UserData> { async function getById(id: number): Promise<UserData> {
return api.get<UserData>(`/users/${id}`) return api.get<UserData>(`/users/${id}`)
} }
@@ -26,11 +33,19 @@ export function useUserService() {
}) })
} }
// Deleting a user is a soft delete server-side: the account is archived
// (kept for referential integrity) rather than removed.
async function remove(id: number): Promise<void> { async function remove(id: number): Promise<void> {
await api.delete(`/users/${id}`, {}, { await api.delete(`/users/${id}`, {}, {
toastSuccessKey: 'users.deleted', toastSuccessKey: 'users.archived',
}) })
} }
return { getAll, getById, create, update, remove } async function restore(id: number): Promise<UserData> {
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
toastSuccessKey: 'users.restored',
})
}
return { getAll, getArchived, getById, create, update, remove, restore }
} }
+35
View File
@@ -0,0 +1,35 @@
/**
* Onglet actif transmis d'une page à l'autre via l'état d'historique
* (`history.state`), SANS le mettre dans l'URL. Sert à préserver l'onglet courant
* du Répertoire (Clients / Prospects / Prestataires) lors de l'aller-retour
* liste ↔ fiche, dans les deux sens (flèche de l'app ET flèche du navigateur).
*
* On reste fidèle à la règle « état d'UI local, pas dans l'URL » : l'onglet
* voyage dans l'entrée d'historique de la navigation, l'URL ne change pas.
*/
/**
* Lit la clé d'onglet posée dans `history.state.tab` si elle fait partie des
* onglets valides. Retourne `null` sinon : navigation directe / deep link,
* rechargement de page, ou onglet inexistant.
*/
export function readHistoryTab(validKeys: string[]): string | null {
if (typeof window === 'undefined') {
return null
}
const tab = (window.history.state as Record<string, unknown> | null)?.tab
return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
}
/**
* Estampille l'entrée d'historique COURANTE avec l'onglet actif, sans créer de
* nouvelle entrée ni changer l'URL. À appeler juste avant de naviguer vers une
* fiche : au retour via la flèche du navigateur (popstate), cette entrée
* « liste » est restaurée avec son onglet.
*/
export function stampHistoryTab(tab: string): void {
if (typeof window === 'undefined') {
return
}
window.history.replaceState({ ...window.history.state, tab }, '')
}
+8 -1
View File
@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down $(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions fix-uploads-perm
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi) # Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -81,6 +81,13 @@ migration-migrate:
sync-permissions: sync-permissions:
$(SYMFONY_CONSOLE) app:sync-permissions $(SYMFONY_CONSOLE) app:sync-permissions
# Le volume nommé `uploads_data` est créé root:root par Docker (il masque le
# bind-mount), or PHP-FPM tourne en www-data (= uid host) : sans ce chown, les
# uploads (documents de compte-rendu, avatars, justificatifs…) échouent en local
# avec « mkdir(): Permission denied ». Idempotent — relancé par `install`/`reset`.
fix-uploads-perm:
$(EXEC_PHP_ROOT) chown -R www-data:www-data /var/www/html/var/uploads
fixtures: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
* removing the row, preserving referential integrity (tasks, time entries,
* notifications…). Existing users are kept active (archived = false).
*/
final class Version20260626153721 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add archived flag on user (soft delete)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP archived');
}
}
+4
View File
@@ -13,6 +13,10 @@
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<!-- API Platform's serializer/metadata boot is memory-hungry on the first
call in a process (cold phpdoc + serializer metadata). 128M is too
tight for non-paginated collections such as GET /api/users. -->
<ini name="memory_limit" value="512M" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" /> <server name="KERNEL_CLASS" value="App\Kernel" />
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function sprintf;
/**
* Recreates user rows that are still referenced by other tables but no longer
* exist (legacy hard-deletes performed before the foreign keys enforced
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
* (tasks, time entries, notifications…) is preserved and references become
* valid again, fixing the serialization crash (EntityNotFoundException), but
* the accounts cannot log in and are hidden from selectable user lists.
*
* Idempotent and non-destructive — nothing is deleted.
*/
#[AsCommand(
name: 'app:restore-missing-users',
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
)]
final class RestoreMissingUsersCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
// 1. Discover every (table, column) that references "user" via a foreign key.
$references = $this->connection->fetchAllAssociative(<<<'SQL'
SELECT t.relname AS child_table, a.attname AS child_col
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_class rt ON rt.oid = c.confrelid
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.contype = 'f' AND rt.relname = 'user'
ORDER BY t.relname, a.attname
SQL);
// 2. Collect distinct orphan ids across all those columns.
$missingIds = [];
foreach ($references as $ref) {
$table = $ref['child_table'];
$col = $ref['child_col'];
$ids = $this->connection->fetchFirstColumn(sprintf(
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
$this->connection->quoteIdentifier($col),
$this->connection->quoteIdentifier($table),
));
foreach ($ids as $id) {
$missingIds[(int) $id] = true;
}
}
$missingIds = array_keys($missingIds);
sort($missingIds);
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
if ([] === $missingIds) {
$io->success('No missing users referenced. Nothing to restore.');
return Command::SUCCESS;
}
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
if ($dryRun) {
$io->note('Dry run — no user recreated.');
return Command::SUCCESS;
}
// 3. Recreate each missing user as an archived placeholder, preserving its id.
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
$created = 0;
foreach ($missingIds as $id) {
$inserted = $this->connection->executeStatement(
<<<'SQL'
INSERT INTO "user"
(id, username, first_name, last_name, roles, password, created_at,
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
initial_leave_balance, archived)
VALUES
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
false, 1.0, 25.0, '06-01', 0.0, true)
ON CONFLICT (id) DO NOTHING
SQL,
[
'id' => $id,
'username' => sprintf('deleted-user-%d', $id),
'firstName' => 'Compte',
'lastName' => sprintf('supprimé #%d', $id),
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
'password' => $hash,
],
);
// ON CONFLICT may have skipped an already-present row — only count real inserts.
if ($inserted > 0) {
++$created;
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
} else {
$io->writeln(sprintf(' • user #%d already present — skipped', $id));
}
}
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
return Command::SUCCESS;
}
}
+29 -1
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity; namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Enum\ContractType; use App\Module\Core\Domain\Enum\ContractType;
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
@@ -47,7 +50,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
), ),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
new Get( new Get(
uriTemplate: '/users/{id}/rbac', uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')", security: "is_granted('core.users.manage')",
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
], ],
denormalizationContext: ['groups' => ['user:write']], denormalizationContext: ['groups' => ['user:write']],
)] )]
// Archived users are hidden from the default /users collection by
// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true.
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[Auditable] #[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')] #[ORM\Table(name: '`user`')]
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $avatarFileName = null; private ?string $avatarFileName = null;
/**
* Soft-delete flag. Archived users are kept for referential integrity
* (tasks, time entries, notifications…) but cannot log in and are hidden
* from selectable user lists.
*/
#[ORM\Column(options: ['default' => false])]
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private bool $archived = false;
// --- HR / absence management fields (readable only by an admin or the user themselves) --- // --- HR / absence management fields (readable only by an admin or the user themselves) ---
/** Whether this user is an employee subject to absence management. */ /** Whether this user is an employee subject to absence management. */
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
return (string) $this->username; return (string) $this->username;
} }
public function isArchived(): bool
{
return $this->archived;
}
public function setArchived(bool $archived): static
{
$this->archived = $archived;
return $this;
}
/** @return list<string> */ /** @return list<string> */
public function getRoles(): array public function getRoles(): array
{ {
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function array_key_exists;
/**
* Hides archived (soft-deleted) users from the `/users` collection so they are
* no longer offered as assignees/collaborators, while existing references to
* them (already stored on tasks, time entries…) keep resolving normally.
*
* An admin can opt back in to see archived users — e.g. to restore one — by
* passing the `archived` query filter explicitly (`?archived=true`), in which
* case the BooleanFilter declared on User handles the predicate instead.
*/
final readonly class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
{
public function __construct(private Security $security) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (User::class !== $resourceClass) {
return;
}
// Let an admin explicitly query archived users via ?archived=...
$filters = $context['filters'] ?? [];
if (array_key_exists('archived', $filters) && $this->security->isGranted('ROLE_ADMIN')) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* Soft-delete processor wired on the User `Delete` operation: instead of
* removing the row (which would orphan every task / time entry / notification
* referencing it and break their serialization), the user is archived. The
* account is kept for referential integrity but can no longer log in
* (ArchivedUserChecker) and is hidden from selectable user lists
* (ExcludeArchivedUserExtension).
*
* @implements ProcessorInterface<User, null|User>
*/
final readonly class UserArchiveProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
{
assert($data instanceof User);
// Prevent an admin from archiving (locking out) their own account.
$current = $this->security->getUser();
if ($current instanceof User && $current->getId() === $data->getId()) {
throw new AccessDeniedHttpException('You cannot archive your own account.');
}
if ($data->isArchived()) {
return null;
}
$data->setArchived(true);
$data->setApiToken(null);
$this->em->flush();
return null;
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Rejects authentication for archived (soft-deleted) users, both at password
* login and on every JWT-authenticated request, so an archived account is
* effectively locked out while its data is preserved.
*/
final class ArchivedUserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
{
if ($user instanceof User && $user->isArchived()) {
throw new CustomUserMessageAccountStatusException('This account has been archived.');
}
}
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
}
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private ?ClientInterface $client = null; private ?ClientInterface $client = null;
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
#[ORM\ManyToOne(targetEntity: Workflow::class)] #[ORM\ManyToOne(targetEntity: Workflow::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')] #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
#[Groups(['project:read', 'project:write', 'task:read'])] #[Groups(['project:read', 'project:write', 'task:read'])]
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
private ?Workflow $workflow = null; private ?Workflow $workflow = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use RuntimeException;
/**
* Assigns the default workflow to a project when none was provided.
* Guarantees the NOT NULL workflow_id constraint across every persistence
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
*/
final readonly class ProjectDefaultWorkflowListener
{
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
public function prePersist(Project $project, PrePersistEventArgs $args): void
{
if (null !== $project->getWorkflow()) {
return;
}
$default = $this->workflowRepository->findDefault()
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
if (null === $default) {
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
}
$project->setWorkflow($default);
}
}
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface; use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project; use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer; use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')] #[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
class CreateProjectTool class CreateProjectTool
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository, private readonly ClientRepositoryInterface $clientRepository,
private readonly WorkflowRepositoryInterface $workflowRepository,
private readonly Security $security, private readonly Security $security,
) {} ) {}
@@ -30,6 +32,7 @@ class CreateProjectTool
?string $description = null, ?string $description = null,
?string $color = null, ?string $color = null,
?int $clientId = null, ?int $clientId = null,
?int $workflowId = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_USER')) { if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.'); throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -52,6 +55,14 @@ class CreateProjectTool
} }
$project->setClient($client); $project->setClient($client);
} }
if (null !== $workflowId) {
$workflow = $this->workflowRepository->findById($workflowId);
if (null === $workflow) {
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
}
$project->setWorkflow($workflow);
}
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
$this->entityManager->persist($project); $this->entityManager->persist($project);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Covers the soft-delete behaviour: deleting a user archives it (the row is
* kept so referencing tasks/time entries still serialize), archived users are
* hidden from the default collection but an admin can list and restore them.
*
* @internal
*/
final class UserArchiveApiTest extends WebTestCase
{
public function testDeleteArchivesUserInsteadOfRemovingIt(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$target = $this->createUser($em, 'archive-target-'.uniqid());
$em->flush();
$targetId = $target->getId();
$this->loginAdmin($client);
$client->request('DELETE', '/api/users/'.$targetId);
self::assertResponseStatusCodeSame(204);
$em->clear();
$reloaded = $em->getRepository(User::class)->find($targetId);
self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)');
self::assertTrue($reloaded->isArchived(), 'User must be flagged archived');
self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive');
}
public function testAdminCannotArchiveOwnAccount(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$this->loginAdmin($client);
$adminId = $this->userId('admin');
$client->request('DELETE', '/api/users/'.$adminId);
self::assertResponseStatusCodeSame(403);
$em->clear();
$admin = $em->getRepository(User::class)->find($adminId);
self::assertFalse($admin->isArchived(), 'Admin must not have archived itself');
}
public function testArchivedUserIsHiddenFromDefaultCollection(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list');
}
public function testAdminCanListArchivedUsersViaFilter(): void
{
$client = self::createClient();
$username = $this->createArchivedUser();
$this->loginAdmin($client);
$client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']);
self::assertResponseIsSuccessful();
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true');
}
public function testAdminCanRestoreUserViaPatch(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser($em, 'restore-target-'.uniqid());
$user->setArchived(true);
$em->flush();
$userId = $user->getId();
$em->clear();
$this->loginAdmin($client);
$client->request('PATCH', '/api/users/'.$userId, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['archived' => false]));
self::assertResponseIsSuccessful();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($userId);
self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user');
}
private function createArchivedUser(): string
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$username = 'archived-'.uniqid();
$user = $this->createUser($em, $username);
$user->setArchived(true);
$em->flush();
$em->clear();
return $username;
}
private function createUser(EntityManagerInterface $em, string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$em->persist($user);
return $user;
}
private function loginAdmin(KernelBrowser $client): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
private function userId(string $username): int
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
return $user->getId();
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
* - avec workflow → le workflow choisi est conservé.
*
* @internal
*/
final class ProjectCreationWorkflowTest extends WebTestCase
{
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet sans workflow',
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('workflow', $data);
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
}
public function testCreateProjectWithExplicitWorkflow(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
?? $em->getRepository(Workflow::class)->findOneBy([]);
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
$client->loginUser($this->createManager($em));
$client->request('POST', '/api/projects', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'code' => $this->randomCode(),
'name' => 'Projet avec workflow',
'workflow' => '/api/workflows/'.$workflow->getId(),
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
}
private function createManager(EntityManagerInterface $em): User
{
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
$user = new User();
$user->setUsername('proj-create-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->addDirectPermission($permission);
$em->persist($user);
$em->flush();
return $user;
}
private function randomCode(): string
{
$letters = '';
for ($i = 0; $i < 6; ++$i) {
$letters .= chr(random_int(65, 90));
}
return $letters;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Infrastructure\Security\ArchivedUserChecker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\InMemoryUser;
/**
* @internal
*/
final class ArchivedUserCheckerTest extends TestCase
{
public function testArchivedUserIsRejectedPreAuth(): void
{
$user = new User()->setArchived(true);
$this->expectException(CustomUserMessageAccountStatusException::class);
new ArchivedUserChecker()->checkPreAuth($user);
}
public function testActiveUserPassesPreAuth(): void
{
$user = new User()->setArchived(false);
new ArchivedUserChecker()->checkPreAuth($user);
$this->addToAssertionCount(1);
}
public function testNonAppUserIsIgnored(): void
{
// A user that is not our entity must not be rejected by this checker.
new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null));
$this->addToAssertionCount(1);
}
}