Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2334789ae | |||
| fc18200d63 | |||
| 1ab2eeccca | |||
| 95befb776e | |||
| 2df7a218bc | |||
| 4e7f339dc1 | |||
| 93852875ad | |||
| bbd8a38c95 | |||
| 0ee164c302 | |||
| d56381b4b8 |
@@ -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
|
||||||
@@ -129,8 +129,9 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
|||||||
## Déploiement (prod Docker)
|
## Déploiement (prod Docker)
|
||||||
|
|
||||||
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
|
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
|
||||||
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
|
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → **`app:assign-default-roles`** → cache clear/warmup
|
||||||
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
|
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
|
||||||
|
- **Rattachement au rôle de base** : deux systèmes de rôles coexistent — le legacy `User::$roles` (`ROLE_USER`/`ROLE_ADMIN`, tableau Symfony) et le RBAC `User::$rbacRoles` (table `user_role`). **Aucun pont automatique** : `getEffectivePermissions()` ne lit que les `rbacRoles` + permissions directes. Un user doit donc être **explicitement rattaché** au rôle RBAC « user » pour hériter de ses permissions. C'est garanti automatiquement par `UserDefaultRoleListener` (prePersist, tout nouveau user) et `app:assign-default-roles` (backfill idempotent des users existants, lancé au déploiement). Symptôme si manquant : un non-admin avec des permissions sur le rôle « user » ne voit **rien** car son `effectivePermissions` reste `[]`. Les modifs de permissions d'un rôle sont **instantanées** côté backend (recalcul à chaque requête, sans cache) ; le frontend les reflète au prochain chargement de page (cache de session Pinia).
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: /
|
||||||
|
|||||||
@@ -54,3 +54,10 @@ when@prod:
|
|||||||
channels: [deprecation]
|
channels: [deprecation]
|
||||||
path: "%kernel.logs_dir%/deprecations.log"
|
path: "%kernel.logs_dir%/deprecations.log"
|
||||||
max_files: 7
|
max_files: 7
|
||||||
|
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service defini
|
||||||
|
# dans sentry.yaml). Hors fingers_crossed : envoi immediat, independamment
|
||||||
|
# du buffer fichier. On exclut le bruit (event, doctrine, deprecation).
|
||||||
|
sentry:
|
||||||
|
type: service
|
||||||
|
id: Sentry\Monolog\Handler
|
||||||
|
channels: ["!event", "!doctrine", "!deprecation"]
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ security:
|
|||||||
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:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ when@prod:
|
|||||||
|
|
||||||
sentry:
|
sentry:
|
||||||
dsn: '%env(SENTRY_DSN)%'
|
dsn: '%env(SENTRY_DSN)%'
|
||||||
# Capture les exceptions levees par le kernel (comportement par defaut).
|
# Capture des erreurs fatales PHP via le handler. On DESACTIVE le listener
|
||||||
register_error_listener: true
|
# kernel pour eviter les doublons avec le handler Monolog (ci-dessous) : les
|
||||||
|
# exceptions du kernel sont deja logguees par Symfony => remontees via Monolog.
|
||||||
|
register_error_listener: false
|
||||||
register_error_handler: true
|
register_error_handler: true
|
||||||
options:
|
options:
|
||||||
environment: '%env(APP_ENV)%'
|
environment: '%env(APP_ENV)%'
|
||||||
@@ -21,3 +23,13 @@ when@prod:
|
|||||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||||
|
|
||||||
|
# Handler Monolog -> Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip
|
||||||
|
# (en plus des erreurs fatales). Les $logger->error(...) metier deviennent des Issues.
|
||||||
|
# Le filtre ignore_exceptions ci-dessus s'applique aussi a ces evenements.
|
||||||
|
services:
|
||||||
|
Sentry\Monolog\Handler:
|
||||||
|
arguments:
|
||||||
|
$hub: '@Sentry\State\HubInterface'
|
||||||
|
$level: !php/const Monolog\Level::Error
|
||||||
|
$bubble: true
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
||||||
|
|
||||||
|
App\Module\Core\Infrastructure\EventListener\UserDefaultRoleListener:
|
||||||
|
tags:
|
||||||
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Core\Domain\Entity\User', 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%'
|
||||||
|
|||||||
+7
-3
@@ -38,12 +38,16 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
// Plus de gate de rôle au niveau section : chaque item porte sa propre
|
||||||
|
// permission (RBAC fin), alignée sur la sécurité backend et les middlewares
|
||||||
|
// de page. La section s'affiche dès qu'au moins un item est autorisé.
|
||||||
'label' => 'sidebar.admin.section',
|
'label' => 'sidebar.admin.section',
|
||||||
'icon' => 'mdi:cog-outline',
|
'icon' => 'mdi:cog-outline',
|
||||||
'roles' => ['ROLE_ADMIN'],
|
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
// team-absences : le module Absence est encore gardé par ROLE_ADMIN côté
|
||||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
// backend (pas de permission absence.* câblée) → on reste sur un gate de rôle.
|
||||||
|
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence', 'roles' => ['ROLE_ADMIN']],
|
||||||
|
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory', 'permission' => ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view']],
|
||||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.46'
|
app.version: '0.4.50'
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
return navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate the route on the RBAC permission(s) declared via definePageMeta.
|
||||||
|
// A string requires that single permission; an array requires ANY of them.
|
||||||
|
// ROLE_ADMIN bypasses everything through usePermissions().can().
|
||||||
|
const required = to.meta.permission
|
||||||
|
|
||||||
|
if (required === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { canAny } = usePermissions()
|
||||||
|
const codes = Array.isArray(required) ? required : [required]
|
||||||
|
|
||||||
|
if (!canAny(codes)) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
// Augments Nuxt page meta with the RBAC permission gate consumed by the
|
||||||
|
// `permission` route middleware. A string requires that single permission;
|
||||||
|
// an array requires ANY of the listed permissions.
|
||||||
|
declare module '#app' {
|
||||||
|
interface PageMeta {
|
||||||
|
permission?: string | string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
permission?: string | string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -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>
|
|
||||||
@@ -432,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",
|
||||||
@@ -926,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": {
|
||||||
@@ -1008,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",
|
||||||
@@ -1022,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é",
|
||||||
@@ -1048,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
|
||||||
|
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()
|
await reload()
|
||||||
|
} finally {
|
||||||
|
deletingDoc.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload(): Promise<void> {
|
async function reload(): Promise<void> {
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport v-if="modelValue" to="body">
|
|
||||||
<Transition name="modal" appear>
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="cancel"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
:label="$t('common.delete')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="$emit('confirm')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
title: string
|
|
||||||
message: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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,17 +1,21 @@
|
|||||||
<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">
|
||||||
|
<!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
|
||||||
|
<h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="removable && !readonly"
|
v-if="removable && !readonly"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute right-3 top-3"
|
button-class="p-0"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
@click="$emit('remove')"
|
@click="$emit('remove')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:label="$t('directory.addresses.fields.label')"
|
:label="$t('directory.addresses.fields.label')"
|
||||||
@@ -20,9 +24,37 @@
|
|||||||
@update:model-value="update('label', $event)"
|
@update:model-value="update('label', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
|
<!-- On commence par le code postal : il alimente la liste des villes (BAN)
|
||||||
allow-create conserve le texte saisi si la BAN ne propose rien
|
et réinitialise ville/rue devenues incohérentes en cas de changement. -->
|
||||||
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
|
<MalioInputText
|
||||||
|
:label="$t('directory.addresses.fields.postalCode')"
|
||||||
|
:model-value="modelValue.postalCode ?? ''"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="onPostalCodeInput"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
||||||
|
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!readonly && !degraded"
|
||||||
|
:model-value="modelValue.city ?? ''"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="$t('directory.addresses.fields.city')"
|
||||||
|
empty-option-label=""
|
||||||
|
group-class="w-full"
|
||||||
|
@update:model-value="onCityChange"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:label="$t('directory.addresses.fields.city')"
|
||||||
|
:model-value="modelValue.city ?? ''"
|
||||||
|
:readonly="readonly"
|
||||||
|
@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">
|
<div class="col-span-2">
|
||||||
<MalioInputAutocomplete
|
<MalioInputAutocomplete
|
||||||
v-if="!readonly"
|
v-if="!readonly"
|
||||||
@@ -31,6 +63,8 @@
|
|||||||
:loading="addressLoading"
|
:loading="addressLoading"
|
||||||
:min-search-length="3"
|
:min-search-length="3"
|
||||||
:allow-create="true"
|
:allow-create="true"
|
||||||
|
:disabled="!canEditStreet"
|
||||||
|
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
|
||||||
:label="$t('directory.addresses.fields.street')"
|
:label="$t('directory.addresses.fields.street')"
|
||||||
:no-results-text="$t('directory.addresses.streetNotFound')"
|
:no-results-text="$t('directory.addresses.streetNotFound')"
|
||||||
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
||||||
@@ -53,32 +87,7 @@
|
|||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="update('streetComplement', $event)"
|
@update:model-value="update('streetComplement', $event)"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<MalioInputText
|
|
||||||
:label="$t('directory.addresses.fields.postalCode')"
|
|
||||||
:model-value="modelValue.postalCode ?? ''"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="onPostalCodeInput"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
|
||||||
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
|
||||||
<MalioSelect
|
|
||||||
v-if="!readonly && !degraded"
|
|
||||||
:model-value="modelValue.city ?? ''"
|
|
||||||
:options="cityOptions"
|
|
||||||
:label="$t('directory.addresses.fields.city')"
|
|
||||||
empty-option-label=""
|
|
||||||
group-class="w-full"
|
|
||||||
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-else
|
|
||||||
:label="$t('directory.addresses.fields.city')"
|
|
||||||
:model-value="modelValue.city ?? ''"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="update('city', $event)"
|
|
||||||
/>
|
|
||||||
</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,17 +1,20 @@
|
|||||||
<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">
|
||||||
|
<h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="removable && !readonly"
|
v-if="removable && !readonly"
|
||||||
icon="mdi:delete-outline"
|
icon="mdi:delete-outline"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="absolute right-3 top-3"
|
button-class="p-0"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
@click="$emit('remove')"
|
@click="$emit('remove')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:label="$t('directory.contacts.fields.lastName')"
|
:label="$t('directory.contacts.fields.lastName')"
|
||||||
:model-value="modelValue.lastName ?? ''"
|
:model-value="modelValue.lastName ?? ''"
|
||||||
@@ -31,21 +34,21 @@
|
|||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="update('jobTitle', $event)"
|
@update:model-value="update('jobTitle', $event)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputEmail
|
||||||
:label="$t('directory.contacts.fields.email')"
|
:label="$t('directory.contacts.fields.email')"
|
||||||
:model-value="modelValue.email ?? ''"
|
:model-value="modelValue.email ?? ''"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:error="emailError"
|
:error="emailError"
|
||||||
@update:model-value="update('email', $event)"
|
@update:model-value="update('email', $event)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
:label="$t('directory.contacts.fields.phonePrimary')"
|
:label="$t('directory.contacts.fields.phonePrimary')"
|
||||||
:model-value="modelValue.phonePrimary ?? ''"
|
:model-value="modelValue.phonePrimary ?? ''"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:error="phonePrimaryError"
|
:error="phonePrimaryError"
|
||||||
@update:model-value="update('phonePrimary', $event)"
|
@update:model-value="update('phonePrimary', $event)"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||||||
:model-value="modelValue.phoneSecondary ?? ''"
|
:model-value="modelValue.phoneSecondary ?? ''"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
@update:model-value="update('phoneSecondary', $event)"
|
@update:model-value="update('phoneSecondary', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -123,7 +136,7 @@ import type { Client } from '~/modules/directory/services/dto/client'
|
|||||||
import { useClientService } from '~/modules/directory/services/clients'
|
import { useClientService } from '~/modules/directory/services/clients'
|
||||||
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['permission'], permission: 'directory.clients.view' })
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -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,8 +208,9 @@ 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: ['permission'], permission: ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view'] })
|
||||||
|
|
||||||
type ProspectRow = Prospect
|
type ProspectRow = Prospect
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -123,7 +136,7 @@ import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
|
|||||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||||
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['permission'], permission: 'directory.providers.view' })
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -138,7 +158,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 { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['permission'], permission: 'directory.prospects.view' })
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ import type { UserData } from '~/services/dto/user-data'
|
|||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['permission'], permission: 'reporting.view' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
useHead({ title: t('reporting.title') })
|
useHead({ title: t('reporting.title') })
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['permission'], permission: 'core.users.view' })
|
||||||
useHead({ title: 'Administration' })
|
useHead({ title: 'Administration' })
|
||||||
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|||||||
@@ -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 }, '')
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
|||||||
echo "==> Syncing RBAC permissions catalog..."
|
echo "==> Syncing RBAC permissions catalog..."
|
||||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
||||||
|
|
||||||
|
echo "==> Assigning base RBAC role 'user' to users missing it (idempotent)..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console app:assign-default-roles
|
||||||
|
|
||||||
echo "==> Clearing cache..."
|
echo "==> Clearing cache..."
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class AppFixtures extends Fixture
|
|||||||
|
|
||||||
public function load(ObjectManager $manager): void
|
public function load(ObjectManager $manager): void
|
||||||
{
|
{
|
||||||
|
// Seed des rôles système RBAC (admin, user) AVANT toute création d'utilisateur :
|
||||||
|
// UserDefaultRoleListener (prePersist) rattache le rôle « user » à chaque user,
|
||||||
|
// le rôle doit donc déjà exister en base au moment du persist().
|
||||||
|
$this->rbacSeeder->ensureSystemRoles();
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
$admin = new User();
|
$admin = new User();
|
||||||
$admin->setUsername('admin');
|
$admin->setUsername('admin');
|
||||||
@@ -826,9 +831,5 @@ class AppFixtures extends Fixture
|
|||||||
$manager->persist($pendingMarriage);
|
$manager->persist($pendingMarriage);
|
||||||
|
|
||||||
$manager->flush();
|
$manager->flush();
|
||||||
|
|
||||||
// Seed des rôles système RBAC (admin, user). Idempotent ; aucune matrice
|
|
||||||
// métier attachée (cf. Décision 4 : les modules métier arrivent en 2.x).
|
|
||||||
$this->rbacSeeder->ensureSystemRoles();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Application\Rbac;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
|
||||||
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
use function count;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garantit que chaque utilisateur porte le rôle RBAC de base « user ».
|
||||||
|
*
|
||||||
|
* Le rôle « user » est le socle commun : il porte les permissions par défaut
|
||||||
|
* des non-admins. Sans rattachement explicite dans user_role,
|
||||||
|
* User::getEffectivePermissions() reste vide — le ROLE_USER legacy n'a aucun
|
||||||
|
* lien avec le rôle RBAC « user ».
|
||||||
|
*/
|
||||||
|
final readonly class DefaultUserRoleAssigner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RoleRepositoryInterface $roles,
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute le rôle « user » à l'utilisateur s'il ne l'a pas déjà.
|
||||||
|
* Ne flush pas : appelé en prePersist (création) ou par le backfill.
|
||||||
|
*/
|
||||||
|
public function ensureDefaultRole(User $user): void
|
||||||
|
{
|
||||||
|
$userRole = $this->roles->findByCode(SystemRoles::USER_CODE);
|
||||||
|
if (null === $userRole) {
|
||||||
|
// Rôle non seedé : dégradation gracieuse, on ne bloque pas la création.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($user->getRbacRoles() as $role) {
|
||||||
|
if (SystemRoles::USER_CODE === $role->getCode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->addRbacRole($userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattache le rôle « user » à tous les utilisateurs qui ne l'ont pas.
|
||||||
|
* Idempotent. Retourne le nombre d'utilisateurs modifiés.
|
||||||
|
*/
|
||||||
|
public function backfill(): int
|
||||||
|
{
|
||||||
|
$userRole = $this->roles->findByCode(SystemRoles::USER_CODE);
|
||||||
|
if (null === $userRole) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<User> $users */
|
||||||
|
$users = $this->em
|
||||||
|
->createQuery('SELECT u FROM '.User::class.' u WHERE :role NOT MEMBER OF u.rbacRoles')
|
||||||
|
->setParameter('role', $userRole)
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
foreach ($users as $user) {
|
||||||
|
$user->addRbacRole($userRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return count($users);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\Console;
|
||||||
|
|
||||||
|
use App\Module\Core\Application\Rbac\DefaultUserRoleAssigner;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:assign-default-roles',
|
||||||
|
description: 'Rattache le rôle RBAC de base « user » à tous les utilisateurs qui ne l\'ont pas.',
|
||||||
|
)]
|
||||||
|
final class AssignDefaultRolesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DefaultUserRoleAssigner $assigner)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$count = $this->assigner->backfill();
|
||||||
|
$io->success(sprintf('%d utilisateur(s) rattaché(s) au rôle « user ».', $count));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\EventListener;
|
||||||
|
|
||||||
|
use App\Module\Core\Application\Rbac\DefaultUserRoleAssigner;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigne le rôle RBAC de base « user » à tout nouvel utilisateur qui n'en a pas,
|
||||||
|
* quel que soit le chemin de persistance (API Platform, fixtures, MCP).
|
||||||
|
*
|
||||||
|
* Sans ça, un user créé n'est rattaché à aucun rôle RBAC et ses permissions
|
||||||
|
* effectives restent vides, peu importe les permissions portées par le rôle.
|
||||||
|
*/
|
||||||
|
final readonly class UserDefaultRoleListener
|
||||||
|
{
|
||||||
|
public function __construct(private DefaultUserRoleAssigner $assigner) {}
|
||||||
|
|
||||||
|
public function prePersist(User $user, PrePersistEventArgs $args): void
|
||||||
|
{
|
||||||
|
$this->assigner->ensureDefaultRole($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace App\Shared\Domain\Sidebar;
|
|||||||
final class SidebarFilter
|
final class SidebarFilter
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:string}>}> $sections
|
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:list<string>|string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:list<string>|string}>}> $sections
|
||||||
* @param list<string> $activeModuleIds
|
* @param list<string> $activeModuleIds
|
||||||
* @param list<string> $activeRoles
|
* @param list<string> $activeRoles
|
||||||
* @param list<string> $activePermissions
|
* @param list<string> $activePermissions
|
||||||
@@ -81,14 +81,21 @@ final class SidebarFilter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param null|list<string>|string $required une permission (string) ou un ensemble (any)
|
||||||
* @param list<string> $activePermissions
|
* @param list<string> $activePermissions
|
||||||
*/
|
*/
|
||||||
private static function permissionSatisfied(?string $required, array $activePermissions): bool
|
private static function permissionSatisfied(array|string|null $required, array $activePermissions): bool
|
||||||
{
|
{
|
||||||
if (null === $required || '' === $required) {
|
if (null === $required || '' === $required || [] === $required) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return in_array($required, $activePermissions, true);
|
foreach ((array) $required as $code) {
|
||||||
|
if (in_array($code, $activePermissions, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Module\Core;
|
||||||
|
|
||||||
|
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
use function uniqid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AssignDefaultRolesCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testBackfillLinksUsersMissingTheUserRole(): void
|
||||||
|
{
|
||||||
|
$kernel = self::bootKernel();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
self::getContainer()->get(RbacSeeder::class)->ensureSystemRoles();
|
||||||
|
|
||||||
|
// Crée un user puis simule l'état « legacy » (aucun rôle RBAC) en retirant
|
||||||
|
// le rôle « user » auto-assigné à la création.
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('backfill-'.uniqid());
|
||||||
|
$user->setPassword('x');
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
foreach ($user->getRbacRoles()->toArray() as $role) {
|
||||||
|
$user->removeRbacRole($role);
|
||||||
|
}
|
||||||
|
$em->flush();
|
||||||
|
$id = $user->getId();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
$before = $em->getRepository(User::class)->find($id);
|
||||||
|
self::assertInstanceOf(User::class, $before);
|
||||||
|
self::assertCount(0, $before->getRbacRoles(), 'Précondition : le user ne doit avoir aucun rôle RBAC.');
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
$tester = new CommandTester(new Application($kernel)->find('app:assign-default-roles'));
|
||||||
|
$tester->execute([]);
|
||||||
|
$tester->assertCommandIsSuccessful();
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$after = $em->getRepository(User::class)->find($id);
|
||||||
|
self::assertInstanceOf(User::class, $after);
|
||||||
|
$codes = array_map(static fn ($role) => $role->getCode(), $after->getRbacRoles()->toArray());
|
||||||
|
self::assertContains(SystemRoles::USER_CODE, $codes, 'Le backfill doit rattacher le rôle « user ».');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Module\Core;
|
||||||
|
|
||||||
|
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Domain\Security\SystemRoles;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
use function array_map;
|
||||||
|
use function uniqid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UserDefaultRoleListenerTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
public function testNewUserReceivesDefaultUserRole(): void
|
||||||
|
{
|
||||||
|
$kernel = self::bootKernel();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
self::getContainer()->get(RbacSeeder::class)->ensureSystemRoles();
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('listener-'.uniqid());
|
||||||
|
$user->setPassword('x');
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
$id = $user->getId();
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($id);
|
||||||
|
self::assertInstanceOf(User::class, $reloaded);
|
||||||
|
|
||||||
|
$codes = array_map(static fn ($role) => $role->getCode(), $reloaded->getRbacRoles()->toArray());
|
||||||
|
self::assertContains(
|
||||||
|
SystemRoles::USER_CODE,
|
||||||
|
$codes,
|
||||||
|
'Un utilisateur fraîchement créé doit être rattaché au rôle RBAC de base « user ».',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,4 +127,33 @@ final class SidebarFilterTest extends TestCase
|
|||||||
$out = SidebarFilter::filter($sections, [], [], ['core.users.view']);
|
$out = SidebarFilter::filter($sections, [], [], ['core.users.view']);
|
||||||
self::assertCount(1, $out['sections'][0]['items']);
|
self::assertCount(1, $out['sections'][0]['items']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testItemWithPermissionArrayIsVisibleWhenAnyGranted(): void
|
||||||
|
{
|
||||||
|
$sections = [[
|
||||||
|
'label' => 's', 'icon' => 'i',
|
||||||
|
'items' => [[
|
||||||
|
'label' => 'a', 'to' => '/a', 'icon' => 'i',
|
||||||
|
'permission' => ['directory.clients.view', 'directory.prospects.view', 'directory.providers.view'],
|
||||||
|
]],
|
||||||
|
]];
|
||||||
|
|
||||||
|
// L'utilisateur ne détient qu'une des permissions listées => item visible (any).
|
||||||
|
$out = SidebarFilter::filter($sections, [], [], ['directory.prospects.view']);
|
||||||
|
self::assertCount(1, $out['sections'][0]['items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItemWithPermissionArrayIsHiddenWhenNoneGranted(): void
|
||||||
|
{
|
||||||
|
$sections = [[
|
||||||
|
'label' => 's', 'icon' => 'i',
|
||||||
|
'items' => [[
|
||||||
|
'label' => 'a', 'to' => '/a', 'icon' => 'i',
|
||||||
|
'permission' => ['directory.clients.view', 'directory.prospects.view'],
|
||||||
|
]],
|
||||||
|
]];
|
||||||
|
|
||||||
|
$out = SidebarFilter::filter($sections, [], [], ['reporting.view']);
|
||||||
|
self::assertSame([], $out['sections']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user