Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ece8146c03 | |||
| 58589e93d0 | |||
| e0d59962d6 | |||
| 3ce40a707f | |||
| 9613857650 | |||
| 2a0918bbfe |
@@ -73,12 +73,20 @@ jobs:
|
||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||
|
||||
- name: Bootstrap test database
|
||||
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||
# qui attendent 409 recoivent 201.
|
||||
run: |
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.48'
|
||||
app.version: '0.1.51'
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<!--
|
||||
Valeurs en dur issues de la maquette Figma (design Starseed) :
|
||||
- sidebar depliee : 232px (w-[232px], repli laisse par defaut 72px)
|
||||
- marge horizontale du contenu sur desktop : 170px (xl:px-[170px])
|
||||
- bande blanche sticky sous la navbar : 47px (h-[47px])
|
||||
A faire evoluer uniquement avec une mise a jour de maquette.
|
||||
-->
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="translatedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
@@ -16,10 +24,10 @@
|
||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<SiteSelector v-if="showSiteSelector"/>
|
||||
<main
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-10 sm:px-6 lg:px-12 xl:px-[170px]">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12"/>
|
||||
class="pointer-events-none sticky top-0 z-30 h-[47px] flex-shrink-0 bg-white"/>
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -88,12 +88,19 @@
|
||||
},
|
||||
"empty": "Aucune activité enregistrée",
|
||||
"no_results": "Aucun résultat pour ces filtres",
|
||||
"error": {
|
||||
"title": "Erreur",
|
||||
"message": "Impossible de charger le journal d'audit. Vérifiez les filtres ou réessayez."
|
||||
},
|
||||
"timeline": {
|
||||
"empty": "Aucun historique",
|
||||
"load_more": "Voir plus"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
"apply": "Voir les résultats",
|
||||
"reset": "Réinitialiser",
|
||||
"date_range": "Date à date",
|
||||
"date_from": "Du",
|
||||
"date_to": "Au",
|
||||
"entity_type": "Type d'entité",
|
||||
@@ -223,6 +230,39 @@
|
||||
"updated": "Site mis à jour avec succès",
|
||||
"deleted": "Site supprimé avec succès"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"title": "Gestion des catégories",
|
||||
"newCategory": "Ajouter",
|
||||
"editCategory": "Modifier la catégorie",
|
||||
"createCategory": "Créer une catégorie",
|
||||
"viewCategory": "Détail de la catégorie",
|
||||
"noCategories": "Aucune catégorie pour l'instant.",
|
||||
"table": {
|
||||
"name": "Nom",
|
||||
"type": "Type"
|
||||
},
|
||||
"form": {
|
||||
"name": "Nom",
|
||||
"type": "Type de catégorie",
|
||||
"typePlaceholder": "Sélectionner un type"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||
"typeRequired": "Le type de catégorie est obligatoire."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer la catégorie",
|
||||
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||
},
|
||||
"toast": {
|
||||
"created": "Catégorie créée avec succès",
|
||||
"updated": "Catégorie mise à jour avec succès",
|
||||
"deleted": "Catégorie supprimée avec succès",
|
||||
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<MalioModal
|
||||
:model-value="modelValue"
|
||||
modal-class="max-w-md"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('admin.categories.delete.title') }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="secondary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="loading"
|
||||
@click="emit('confirm')"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
categoryName: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-bold">
|
||||
{{ headerLabel }}
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||
<MalioInputText
|
||||
v-model="form.name.value"
|
||||
:label="t('admin.categories.form.name')"
|
||||
input-class="w-full"
|
||||
:max-length="120"
|
||||
:error="form.errors.value.name"
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||
number (categoryType id) ; conversion en IRI au moment du save
|
||||
par le composable useCategoryForm. -->
|
||||
<MalioSelect
|
||||
v-model="form.categoryTypeId.value"
|
||||
:options="typeOptions"
|
||||
:label="t('admin.categories.form.type')"
|
||||
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||
:error="form.errors.value.categoryType"
|
||||
:disabled="loadingTypes"
|
||||
/>
|
||||
|
||||
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||
erreurs de validation par champ. -->
|
||||
<p v-if="form.errors.value._global" class="text-sm text-red-600">
|
||||
{{ form.errors.value._global }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="canShowDelete"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canShowSave"
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="form.submitting.value || loadingTypes"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { types, loadingTypes, fetchTypes } = useCategoriesAdmin()
|
||||
// Instance dediee de form pour ce drawer — state isole (cf. useCategoryForm
|
||||
// n'est pas singleton, contrairement a useCategoriesAdmin).
|
||||
const form = useCategoryForm()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
category: Category | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
saved: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Mode du drawer (dérivé du composable `useCategoryForm`) :
|
||||
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||
* champ different de l'original), PATCH au save.
|
||||
*/
|
||||
type DrawerMode = 'create' | 'view' | 'edit'
|
||||
|
||||
const isCreateMode = computed(() => props.category === null)
|
||||
|
||||
const mode = computed<DrawerMode>(() => {
|
||||
if (isCreateMode.value) return 'create'
|
||||
return form.isDirty.value ? 'edit' : 'view'
|
||||
})
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||
return t('admin.categories.viewCategory')
|
||||
})
|
||||
|
||||
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||
// existante et seulement pour les users ayant la permission manage. En mode
|
||||
// creation on affiche un bouton Annuler a la place.
|
||||
const canShowDelete = computed(
|
||||
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||
)
|
||||
|
||||
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||
// Masque en view tant que rien n'a change.
|
||||
const canShowSave = computed(
|
||||
() => mode.value === 'create' || mode.value === 'edit',
|
||||
)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
types.value.map(ct => ({
|
||||
label: ct.label,
|
||||
value: ct.id,
|
||||
})),
|
||||
)
|
||||
|
||||
// Re-initialise le form quand la categorie selectionnee change (clic sur une
|
||||
// autre ligne sans fermer le drawer entre-temps).
|
||||
watch(() => props.category, (cat) => {
|
||||
form.loadFrom(cat)
|
||||
}, { immediate: true })
|
||||
|
||||
// A chaque ouverture du drawer : reload du form + refresh des types (au cas
|
||||
// ou un type aurait ete ajoute en arriere-plan depuis le dernier fetch — pas
|
||||
// d'optimisation cache au M0, le referentiel est petit).
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
form.loadFrom(props.category)
|
||||
fetchTypes()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Sauvegarde : delegue au composable (POST en mode create, PATCH en mode
|
||||
* edit). Le toast succes + mapping erreur 409/422 est gere par le composable.
|
||||
* En cas de succes, on ferme le drawer et on previent le parent pour qu'il
|
||||
* refresh la liste.
|
||||
*/
|
||||
async function handleSave(): Promise<void> {
|
||||
let result: Category | null = null
|
||||
if (mode.value === 'create') {
|
||||
result = await form.submitCreate()
|
||||
} else if (mode.value === 'edit' && props.category) {
|
||||
result = await form.submitUpdate(props.category.id)
|
||||
}
|
||||
if (result) {
|
||||
emit('saved')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Composable d'administration des categories (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise le chargement et le state des deux ressources lues par la page
|
||||
* `/admin/categories` : la liste des categories et le referentiel
|
||||
* CategoryType (utilise dans le select du drawer).
|
||||
*
|
||||
* State singleton au niveau module (meme convention que `useSidebar` /
|
||||
* `useModules` / `useAuditLog`) : reset automatique au logout via
|
||||
* `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
|
||||
* avec state singleton doivent etre reinitialises au logout »), et reset
|
||||
* explicite expose via `resetCategoriesAdmin()` appele depuis
|
||||
* `modules/core/pages/logout.vue`.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||
import type { HydraCollection } from '~/shared/utils/api'
|
||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||
|
||||
/**
|
||||
* Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
|
||||
* cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
|
||||
* toute la liste en un coup. A basculer en pagination serveur quand la
|
||||
* volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
|
||||
*/
|
||||
const HYDRA_NO_PAGINATION = 999
|
||||
|
||||
// State singleton — partage entre tous les composants qui appellent le
|
||||
// composable dans la meme session. Les refs sont declarees au niveau module
|
||||
// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
|
||||
// instance soit creee a chaque appel.
|
||||
const categories = ref<Category[]>([])
|
||||
const types = ref<CategoryType[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingTypes = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function resetCategoriesAdminState(): void {
|
||||
categories.value = []
|
||||
types.value = []
|
||||
loading.value = false
|
||||
loadingTypes.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
|
||||
// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
|
||||
// l'ancien. Le logout volontaire (page logout.vue) appelle directement
|
||||
// `resetCategoriesAdmin()` ci-dessous.
|
||||
onAuthSessionCleared(resetCategoriesAdminState)
|
||||
|
||||
export function useCategoriesAdmin() {
|
||||
const api = useApi()
|
||||
|
||||
/**
|
||||
* Charge la liste des categories. Le serveur exclut les soft-deleted par
|
||||
* defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
|
||||
* serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
|
||||
*
|
||||
* `includeDeleted=true` permet a un user avec `catalog.categories.manage`
|
||||
* de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
|
||||
* option mais on l'expose pour la suite (corbeille future).
|
||||
*
|
||||
* Swallow volontaire : un 403 (user sans permission view) ne doit pas
|
||||
* toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
|
||||
* la page seulement par URL directe et on affiche un tableau vide propre.
|
||||
*/
|
||||
async function fetchAll(includeDeleted = false): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const query: Record<string, unknown> = { itemsPerPage: HYDRA_NO_PAGINATION }
|
||||
if (includeDeleted) {
|
||||
query.includeDeleted = 'true'
|
||||
}
|
||||
const data = await api.get<HydraCollection<Category>>(
|
||||
'/categories',
|
||||
query,
|
||||
{ toast: false },
|
||||
)
|
||||
categories.value = data.member ?? []
|
||||
} catch (e) {
|
||||
categories.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
|
||||
* l'ouverture de la page admin pour que le select du drawer ait deja les
|
||||
* options pretes au moment de la creation/edition.
|
||||
*
|
||||
* Toast desactive : on stocke l'erreur dans `error` plutot que de
|
||||
* spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
|
||||
*/
|
||||
async function fetchTypes(): Promise<void> {
|
||||
loadingTypes.value = true
|
||||
try {
|
||||
const data = await api.get<HydraCollection<CategoryType>>(
|
||||
'/category_types',
|
||||
{ itemsPerPage: HYDRA_NO_PAGINATION },
|
||||
{ toast: false },
|
||||
)
|
||||
types.value = data.member ?? []
|
||||
} catch (e) {
|
||||
types.value = []
|
||||
error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
|
||||
} finally {
|
||||
loadingTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
|
||||
* garantir que la prochaine session reparte sur un state propre meme si
|
||||
* `clearSession()` n'a pas ete declenche (cas logout volontaire).
|
||||
*/
|
||||
function resetCategoriesAdmin(): void {
|
||||
resetCategoriesAdminState()
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
types,
|
||||
loading,
|
||||
loadingTypes,
|
||||
error,
|
||||
fetchAll,
|
||||
fetchTypes,
|
||||
resetCategoriesAdmin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* Composable de formulaire categorie (M0 — Gestion des categories).
|
||||
*
|
||||
* Centralise la logique de validation client + appels API (POST / PATCH /
|
||||
* DELETE) du drawer de creation/edition. Contrairement a
|
||||
* `useCategoriesAdmin` qui porte un state singleton partage entre composants,
|
||||
* ce composable est instancie par formulaire (les refs vivent dans la
|
||||
* fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
|
||||
* isole.
|
||||
*
|
||||
* Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
|
||||
* elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
|
||||
* revalide toujours (defense en profondeur).
|
||||
*
|
||||
* Mapping erreurs API :
|
||||
* - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
|
||||
* - 422 (violations API Platform) → mapping sur les champs concernes
|
||||
* - autre → erreur globale `_global` + toast generique
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
|
||||
|
||||
/**
|
||||
* Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
|
||||
* (status et payload data) pour eviter de typer toute la lib.
|
||||
*/
|
||||
interface ApiFetchError {
|
||||
response?: {
|
||||
status?: number
|
||||
_data?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function useCategoryForm() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
|
||||
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
|
||||
const name = ref('')
|
||||
const categoryTypeId = ref<number | null>(null)
|
||||
|
||||
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
|
||||
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
|
||||
// que rien n'a change en mode consultation).
|
||||
const initialName = ref('')
|
||||
const initialCategoryTypeId = ref<number | null>(null)
|
||||
|
||||
const errors = ref<{
|
||||
name: string
|
||||
categoryType: string
|
||||
_global: string
|
||||
}>({
|
||||
name: '',
|
||||
categoryType: '',
|
||||
_global: '',
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
name.value !== initialName.value
|
||||
|| categoryTypeId.value !== initialCategoryTypeId.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* Pre-remplit le formulaire a partir d'une categorie existante (mode
|
||||
* consultation/edition) ou vide (mode creation). Reinitialise les
|
||||
* erreurs et le snapshot initial pour repartir d'un etat propre.
|
||||
*/
|
||||
function loadFrom(category: Category | null): void {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
if (category) {
|
||||
name.value = category.name
|
||||
categoryTypeId.value = category.categoryType.id
|
||||
initialName.value = category.name
|
||||
initialCategoryTypeId.value = category.categoryType.id
|
||||
} else {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation client miroir des RG back. Renvoie true si tout passe et
|
||||
* peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
|
||||
* mais le serveur retrim de toute facon — pas de risque de divergence.
|
||||
*/
|
||||
function validate(): boolean {
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
const trimmedName = name.value.trim()
|
||||
|
||||
// RG-1.02 — name obligatoire (vide / whitespace-only).
|
||||
if (trimmedName === '') {
|
||||
errors.value.name = t('admin.categories.validation.nameRequired')
|
||||
} else if (trimmedName.length < 2 || trimmedName.length > 120) {
|
||||
// RG-1.04 — longueur 2-120 apres trim.
|
||||
errors.value.name = t('admin.categories.validation.nameLength')
|
||||
}
|
||||
|
||||
// RG-1.05 — categoryType obligatoire.
|
||||
if (categoryTypeId.value === null) {
|
||||
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||
}
|
||||
|
||||
return errors.value.name === '' && errors.value.categoryType === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload POST a partir du state. Le `categoryType` est
|
||||
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
|
||||
* Platform pour referencer une ressource liee. Retourne un object literal
|
||||
* compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
|
||||
* `CategoryCreateInput` ne serait pas assignable a `Record<string, unknown>`
|
||||
* en TS strict).
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
name: name.value.trim(),
|
||||
categoryType: `/api/category_types/${categoryTypeId.value}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe les violations 422 d'API Platform sur les champs du formulaire.
|
||||
* Renvoie true des qu'au moins une violation a ete posee — false sinon
|
||||
* (payload sans violations exploitables, ou tous les `propertyPath` hors
|
||||
* du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
|
||||
* est centralisee dans `shared/utils/api.ts` pour rester reutilisable
|
||||
* sur les futurs drawers de formulaire.
|
||||
*/
|
||||
function mapServerViolations(data: unknown): boolean {
|
||||
const violations = extractApiViolations(data)
|
||||
if (violations.length === 0) return false
|
||||
let mapped = false
|
||||
for (const v of violations) {
|
||||
if (v.propertyPath === 'name') {
|
||||
errors.value.name = v.message
|
||||
mapped = true
|
||||
} else if (v.propertyPath === 'categoryType') {
|
||||
errors.value.categoryType = v.message
|
||||
mapped = true
|
||||
}
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite une erreur API : mappe selon le status, declenche les toasts
|
||||
* appropries. Centralise la logique entre create/update.
|
||||
*
|
||||
* - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
|
||||
* le nom soumis.
|
||||
* - 422 : tentative de mapping fin via les violations API Platform — si au
|
||||
* moins une violation est mappee, pas de toast (erreur affichee inline
|
||||
* sous le champ concerne).
|
||||
* - autre : message global + toast generique. Le toast natif d'useApi
|
||||
* est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
|
||||
* donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
|
||||
*
|
||||
* Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
|
||||
* false sinon (fallback generique).
|
||||
*/
|
||||
function handleApiError(e: unknown, attemptedName: string): boolean {
|
||||
const status = (e as ApiFetchError)?.response?.status
|
||||
const data = (e as ApiFetchError)?.response?._data
|
||||
|
||||
if (status === 409) {
|
||||
const duplicateMessage = t('admin.categories.toast.duplicate', {
|
||||
name: attemptedName,
|
||||
})
|
||||
errors.value.name = duplicateMessage
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: duplicateMessage,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (status === 422 && mapServerViolations(data)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const extracted = extractApiErrorMessage(data)
|
||||
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: errors.value._global,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/categories. Renvoie la categorie creee, ou `null` si la
|
||||
* validation client a echoue ou si le serveur a renvoye une erreur. Le
|
||||
* caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
|
||||
*/
|
||||
async function submitCreate(): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload = buildCreatePayload()
|
||||
try {
|
||||
const created = await api.post<Category>('/categories', payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.created'),
|
||||
})
|
||||
return created
|
||||
} catch (e) {
|
||||
handleApiError(e, String(payload.name))
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
|
||||
* coller a la semantique merge-patch (Content-Type pose par useApi).
|
||||
* Renvoie la categorie mise a jour, ou `null` en cas d'echec.
|
||||
*/
|
||||
async function submitUpdate(id: number): Promise<Category | null> {
|
||||
if (!validate()) return null
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (name.value !== initialName.value) {
|
||||
payload.name = name.value.trim()
|
||||
}
|
||||
if (categoryTypeId.value !== initialCategoryTypeId.value) {
|
||||
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
|
||||
}
|
||||
// Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
|
||||
// empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
|
||||
// on protege le composable contre un appel direct mal utilise.
|
||||
if (Object.keys(payload).length === 0) {
|
||||
submitting.value = false
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const updated = await api.patch<Category>(`/categories/${id}`, payload, {
|
||||
toast: false,
|
||||
})
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.updated'),
|
||||
})
|
||||
return updated
|
||||
} catch (e) {
|
||||
const attemptedName = typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: name.value.trim()
|
||||
handleApiError(e, attemptedName)
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
|
||||
* `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
|
||||
* false sinon (avec toast erreur deja affiche).
|
||||
*/
|
||||
async function submitDelete(id: number): Promise<boolean> {
|
||||
submitting.value = true
|
||||
errors.value._global = ''
|
||||
try {
|
||||
await api.delete(`/categories/${id}`, {}, { toast: false })
|
||||
toast.success({
|
||||
title: 'Succès',
|
||||
message: t('admin.categories.toast.deleted'),
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
handleApiError(e, name.value)
|
||||
return false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset complet du formulaire — utilise par le drawer apres save ou
|
||||
* fermeture pour ne pas garder de donnees stale entre deux ouvertures.
|
||||
*/
|
||||
function reset(): void {
|
||||
name.value = ''
|
||||
categoryTypeId.value = null
|
||||
initialName.value = ''
|
||||
initialCategoryTypeId.value = null
|
||||
errors.value = { name: '', categoryType: '', _global: '' }
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
name,
|
||||
categoryTypeId,
|
||||
errors,
|
||||
submitting,
|
||||
isDirty,
|
||||
// Methods
|
||||
loadFrom,
|
||||
validate,
|
||||
submitCreate,
|
||||
submitUpdate,
|
||||
submitDelete,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('admin.categories.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
:label="t('admin.categories.newCategory')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des categories. Affichage exhaustif (volumetrie cible
|
||||
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
|
||||
reste cosmetique tant qu'aucun slice client n'est cable : a
|
||||
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="categoryItems"
|
||||
:total-items="categories.length"
|
||||
:row-clickable="true"
|
||||
:empty-message="t('admin.categories.noCategories')"
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
|
||||
<!-- Drawer creation / consultation / edition. -->
|
||||
<CategoryDrawer
|
||||
v-model="drawerOpen"
|
||||
:category="selectedCategory"
|
||||
@saved="onCategorySaved"
|
||||
@delete="onDeleteRequest"
|
||||
/>
|
||||
|
||||
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
|
||||
<CategoryDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:category-name="categoryToDelete?.name ?? ''"
|
||||
:loading="deleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Category } from '~/modules/catalog/types/category'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
|
||||
const { submitDelete } = useCategoryForm()
|
||||
|
||||
useHead({ title: t('admin.categories.title') })
|
||||
|
||||
const canManage = computed(() => can('catalog.categories.manage'))
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const deleteModalOpen = ref(false)
|
||||
const categoryToDelete = ref<Category | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
|
||||
// on aplatit en label lisible pour l'affichage.
|
||||
const columns = [
|
||||
{ key: 'name', label: t('admin.categories.table.name') },
|
||||
{ key: 'typeLabel', label: t('admin.categories.table.type') },
|
||||
]
|
||||
|
||||
const categoryItems = computed(() =>
|
||||
categories.value.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
typeLabel: cat.categoryType?.label ?? '',
|
||||
})),
|
||||
)
|
||||
|
||||
function getCategoryById(id: number): Category | undefined {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
function onRowClick(item: Record<string, unknown>) {
|
||||
const category = getCategoryById(item.id as number)
|
||||
if (category) openEditDrawer(category)
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
selectedCategory.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditDrawer(category: Category) {
|
||||
selectedCategory.value = category
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
if (!selectedCategory.value) return
|
||||
categoryToDelete.value = selectedCategory.value
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete via le composable de form (qui gere toast + erreur). Refresh
|
||||
* de la liste a la fin pour retirer la ligne. L'index unique partiel
|
||||
* autorise une recreation ulterieure avec le meme couple (name, type) —
|
||||
* RG-1.07.
|
||||
*/
|
||||
async function handleDelete(): Promise<void> {
|
||||
if (!categoryToDelete.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
const ok = await submitDelete(categoryToDelete.value.id)
|
||||
if (ok) {
|
||||
deleteModalOpen.value = false
|
||||
categoryToDelete.value = null
|
||||
drawerOpen.value = false
|
||||
await fetchAll()
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySaved() {
|
||||
fetchAll()
|
||||
}
|
||||
|
||||
// Chargement initial des deux ressources (liste + referentiel des types).
|
||||
// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
|
||||
// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
|
||||
onMounted(() => {
|
||||
fetchAll()
|
||||
fetchTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Types front du module Catalog (M0 — Gestion des categories).
|
||||
*
|
||||
* Contrats API consommes :
|
||||
* - GET /api/categories → HydraCollection<Category>
|
||||
* - GET /api/categories/{id} → Category
|
||||
* - POST /api/categories → body { name, categoryType: IRI }
|
||||
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||
*
|
||||
* Notes :
|
||||
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||
* et de l'username pour l'affichage courant.
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||
*/
|
||||
export interface CategoryType {
|
||||
id: number
|
||||
code: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||
*/
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
categoryType: CategoryType
|
||||
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy: User | null
|
||||
updatedBy: User | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||
* IRI Hydra (ex. `/api/category_types/3`).
|
||||
*/
|
||||
export interface CategoryCreateInput {
|
||||
name: string
|
||||
categoryType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||
* optionnels (modification partielle).
|
||||
*/
|
||||
export interface CategoryUpdateInput {
|
||||
name?: string
|
||||
categoryType?: string
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<!-- Accordeon de permissions groupees par module : un panneau par module,
|
||||
avec compteur (selectionnees/total) dans le titre, case "Tout selectionner"
|
||||
et liste des permissions individuelles. Source unique de cette UX, utilisee
|
||||
par RoleDrawer (permissions du role) et UserRbacDrawer (permissions directes). -->
|
||||
<MalioAccordion v-model="openModules">
|
||||
<MalioAccordionItem
|
||||
v-for="group in groupsByModule"
|
||||
:key="group.module"
|
||||
:value="group.module"
|
||||
:title="`${group.module} (${selectedCountFor(group)}/${group.permissions.length})`"
|
||||
header-class="capitalize"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Tout selectionner pour ce module -->
|
||||
<MalioCheckbox
|
||||
:id="`${idPrefix}-group-${group.module}`"
|
||||
:label="t('admin.roles.permissions.selectAll')"
|
||||
:model-value="allSelectedFor(group)"
|
||||
label-class="font-semibold text-sm text-neutral-700"
|
||||
@update:model-value="(val: boolean) => emit('toggle-all', group.module, val)"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in group.permissions"
|
||||
:id="`${idPrefix}-perm-${perm.id}`"
|
||||
:key="perm.id"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => emit('toggle', perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PermissionModule } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
/** Groupes de permissions a afficher, un par module. */
|
||||
groupsByModule: PermissionModule[]
|
||||
/** Ids des permissions actuellement selectionnees. */
|
||||
selectedIds: Set<number>
|
||||
/** Prefixe pour les ids HTML : evite les collisions si plusieurs accordeons coexistent (ex: "role" vs "direct"). */
|
||||
idPrefix: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
'toggle-all': [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Modules ouverts dans l'accordeon (mode multiple). Etat local : chaque instance
|
||||
// du composant garde sa propre liste, pas de partage entre drawers.
|
||||
const openModules = ref<string[]>([])
|
||||
|
||||
// Nombre de permissions selectionnees pour un module donne.
|
||||
function selectedCountFor(group: PermissionModule): number {
|
||||
return group.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
}
|
||||
|
||||
// Vrai si toutes les permissions du module sont selectionnees.
|
||||
function allSelectedFor(group: PermissionModule): boolean {
|
||||
return group.permissions.length > 0 && selectedCountFor(group) === group.permissions.length
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<!-- En-tete du groupe avec checkbox "tout selectionner" -->
|
||||
<div class="flex items-center gap-3 bg-neutral-50 px-4 py-3 border-b border-neutral-200">
|
||||
<MalioCheckbox
|
||||
:id="`group-${module}`"
|
||||
:label="moduleLabel"
|
||||
:model-value="allSelected"
|
||||
label-class="font-semibold text-sm text-neutral-700 capitalize"
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
<span class="ml-auto text-xs text-neutral-400">
|
||||
{{ selectedCount }}/{{ permissions.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Liste des permissions individuelles -->
|
||||
<div class="grid grid-cols-1 gap-1 p-3 sm:grid-cols-2">
|
||||
<MalioCheckbox
|
||||
v-for="perm in permissions"
|
||||
:key="perm.id"
|
||||
:id="`perm-${perm.id}`"
|
||||
:label="perm.label"
|
||||
:model-value="selectedIds.has(perm.id)"
|
||||
label-class="text-sm text-neutral-600"
|
||||
@update:model-value="(val: boolean) => togglePermission(perm.id, val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission } from '~/shared/types/rbac'
|
||||
|
||||
const props = defineProps<{
|
||||
module: string
|
||||
moduleLabel: string
|
||||
permissions: Permission[]
|
||||
selectedIds: Set<number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [permissionId: number, selected: boolean]
|
||||
toggleAll: [module: string, selected: boolean]
|
||||
}>()
|
||||
|
||||
// Nombre de permissions selectionnees dans ce groupe
|
||||
const selectedCount = computed(() =>
|
||||
props.permissions.filter(p => props.selectedIds.has(p.id)).length
|
||||
)
|
||||
|
||||
// Vrai si toutes les permissions du groupe sont selectionnees
|
||||
const allSelected = computed(() =>
|
||||
props.permissions.length > 0 && selectedCount.value === props.permissions.length
|
||||
)
|
||||
|
||||
// Emet l'evenement de bascule pour une permission individuelle
|
||||
function togglePermission(id: number, selected: boolean) {
|
||||
emit('toggle', id, selected)
|
||||
}
|
||||
|
||||
// Emet l'evenement de bascule pour toutes les permissions du groupe
|
||||
function toggleAll(selected: boolean) {
|
||||
emit('toggleAll', props.module, selected)
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<!-- Champs du role -->
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -44,55 +50,51 @@
|
||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedPermissionIds"
|
||||
id-prefix="role"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
:disabled="role?.isSystem"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || permissionsLoadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role } from '~/shared/types/rbac'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
||||
drawer-class="w-full max-w-lg"
|
||||
drawer-class="w-full max-w-[450px]"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ t('admin.users.drawer.title', { username: user?.username ?? '' }) }}
|
||||
</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4 py-4">
|
||||
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||
<div
|
||||
@@ -60,18 +66,14 @@
|
||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||
{{ t('admin.roles.permissions.noPermissions') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<PermissionGroup
|
||||
v-for="group in permissionsByModule"
|
||||
:key="group.module"
|
||||
:module="group.module"
|
||||
:module-label="group.module"
|
||||
:permissions="group.permissions"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
<PermissionAccordion
|
||||
v-else
|
||||
:groups-by-module="permissionsByModule"
|
||||
:selected-ids="selectedDirectPermissionIds"
|
||||
id-prefix="direct"
|
||||
@toggle="handleTogglePermission"
|
||||
@toggle-all="handleToggleAll"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||
@@ -103,33 +105,32 @@
|
||||
<EffectivePermissions :permissions="effectivePermissions" />
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || loadFailed"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Permission, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Permission, PermissionModule, Role, UserListItem, UserRbacDetail, EffectivePermission } from '~/shared/types/rbac'
|
||||
import type { Site } from '~/shared/types/sites'
|
||||
|
||||
interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -1,95 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.auditLog.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
||||
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||
leur `label` flottant interne pour ne pas mixer deux patterns de label.
|
||||
A revoir une fois le composant calendar Malio développé -->
|
||||
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||
exposera un datetime picker. Cf. exception documentee dans
|
||||
CLAUDE.md (section "Composants formulaires"). -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_from') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtAfter"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.date_to') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="filters.performedAtBefore"
|
||||
type="datetime-local"
|
||||
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.entity_type') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelectCheckbox
|
||||
v-model="selectedEntityTypes"
|
||||
:options="entityTypeOptions"
|
||||
:display-select-all="true"
|
||||
:display-tag="true"
|
||||
min-width="w-full"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.user') }}
|
||||
</label>
|
||||
<MalioInputText
|
||||
v-model="performedByInput"
|
||||
icon-name="mdi:account-search"
|
||||
input-class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||
{{ t('audit.filters.action') }}
|
||||
</label>
|
||||
<div class="[&>div>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="actionValue"
|
||||
:options="actionOptions"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex justify-end">
|
||||
<PageHeader>
|
||||
{{ t('admin.auditLog.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="text-xs"
|
||||
@click="resetFilters"
|
||||
:label="t('audit.filters.title')"
|
||||
icon-name="mdi:tune"
|
||||
icon-position="left"
|
||||
icon-size="24"
|
||||
button-class="w-[184px] justify-start gap-4 text-black"
|
||||
@click="openFilters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Tableau -->
|
||||
<MalioDataTable
|
||||
class="mt-4"
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
@@ -123,12 +50,99 @@
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<!-- Drawer de filtres : etat brouillon, applique uniquement au clic sur
|
||||
"Voir les resultats". `body-class="p-0"` pour que l'accordeon aille
|
||||
bord a bord (les items portent leur propre px-7). -->
|
||||
<MalioDrawer
|
||||
v-model="filterDrawerOpen"
|
||||
drawer-class="max-w-[450px]"
|
||||
body-class="p-0"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold uppercase">{{ t('audit.filters.title') }}</h2>
|
||||
</template>
|
||||
|
||||
<MalioAccordion>
|
||||
<!-- Dates : deux champs date+heure Du / Au (champs datetime a l'origine) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.date_range')" value="dates">
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-4">
|
||||
<span>{{ t('audit.filters.date_from') }}</span>
|
||||
<!-- Borne le picker "Du" par la valeur "Au" pour interdire une plage
|
||||
inversee a la saisie (le backend renverrait silencieusement 0 ligne). -->
|
||||
<MalioDateTime
|
||||
v-model="draftDateFrom"
|
||||
:max="draftDateTo ?? undefined"
|
||||
/>
|
||||
<span>{{ t('audit.filters.date_to') }}</span>
|
||||
<MalioDateTime
|
||||
v-model="draftDateTo"
|
||||
:min="draftDateFrom ?? undefined"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Type d'entite : cases a cocher (multi-selection) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.entity_type')" value="entity">
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioCheckbox
|
||||
v-for="opt in entityTypeOptions"
|
||||
:id="`filter-entity-${opt.value}`"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:model-value="draftEntityTypes.includes(opt.value)"
|
||||
@update:model-value="(val: boolean) => toggleEntity(opt.value, val)"
|
||||
/>
|
||||
</div>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Action : boutons radio (selection unique, '' = toutes) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.action')" value="action">
|
||||
<MalioRadioButton
|
||||
v-for="opt in actionOptions"
|
||||
:key="opt.value"
|
||||
v-model="draftAction"
|
||||
name="audit-action"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
|
||||
<!-- Utilisateur : recherche texte (ILIKE partiel cote backend) -->
|
||||
<MalioAccordionItem :title="t('audit.filters.user')" value="user">
|
||||
<MalioInputText
|
||||
v-model="draftPerformedBy"
|
||||
icon-name="mdi:account-search"
|
||||
/>
|
||||
</MalioAccordionItem>
|
||||
</MalioAccordion>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="t('audit.filters.reset')"
|
||||
button-class="w-[150px]"
|
||||
@click="resetFilters"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('audit.filters.apply')"
|
||||
button-class="w-[170px]"
|
||||
@click="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
|
||||
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||
<MalioDrawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
drawer-class="max-w-2xl"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ drawerTitle }}
|
||||
</h2>
|
||||
</template>
|
||||
<div v-if="selectedEntry">
|
||||
<AuditLogDetail :entry="selectedEntry" />
|
||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||
@@ -149,12 +163,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
const { can } = usePermissions()
|
||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||
const toast = useToast()
|
||||
|
||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
||||
@@ -173,8 +188,11 @@ if (!can('core.audit_log.view')) {
|
||||
|
||||
useHead({ title: t('admin.auditLog.title') })
|
||||
|
||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||
// persiste dans l'URL (cf. regle CLAUDE.md "Tableau : pas de persistance URL").
|
||||
// `performedAtAfter`/`performedAtBefore` stockent une date+heure ISO naive
|
||||
// (`YYYY-MM-DDTHH:MM:00`, fournie par MalioDateTime), convertie en ISO UTC
|
||||
// au moment du fetch.
|
||||
const filters = reactive<AuditLogFilters>({
|
||||
performedAtAfter: undefined,
|
||||
performedAtBefore: undefined,
|
||||
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
|
||||
itemsPerPage: 10,
|
||||
})
|
||||
|
||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||
// fermant le drawer sans relancer de requete.
|
||||
const filterDrawerOpen = ref(false)
|
||||
const draftDateFrom = ref<string | null>(null)
|
||||
const draftDateTo = ref<string | null>(null)
|
||||
const draftEntityTypes = ref<string[]>([])
|
||||
const draftAction = ref<string>('')
|
||||
const draftPerformedBy = ref<string>('')
|
||||
|
||||
// Liste des entity types (distincts) pour alimenter les cases a cocher.
|
||||
const entityTypes = ref<string[]>([])
|
||||
// On garde l'identifiant technique comme `value` pour l'envoi API, mais on
|
||||
// affiche le libelle traduit quand il existe (fallback: identifiant brut).
|
||||
const entityTypeOptions = computed(() =>
|
||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||
)
|
||||
|
||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||
// pas binder directement un `string | undefined` reactive.
|
||||
const performedByInput = ref<string>('')
|
||||
|
||||
// Action : '' = "toutes les actions". On declare l'option dans `actionOptions`
|
||||
// plutot que via `emptyOptionLabel` (qui n'inclut pas l'option vide dans
|
||||
// `props.options`, donc `selectedLabel` reste vide). On evite aussi `value: null`
|
||||
// car MalioSelect grise visuellement les options dont la valeur est `null`
|
||||
// (Select.vue:137) — on utilise donc une chaine vide comme sentinelle.
|
||||
const actionValue = ref<string>('')
|
||||
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||
const actionOptions = [
|
||||
{ value: '', label: t('audit.filters.all_actions') },
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
|
||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||
let requestToken = 0
|
||||
|
||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||
// explicitement apres la liberation.
|
||||
let watchersSuspended = false
|
||||
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||
// reouverture reflete les filtres actifs.
|
||||
function openFilters(): void {
|
||||
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||
draftDateTo.value = filters.performedAtBefore ?? null
|
||||
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||
? [...filters.entityType]
|
||||
: (filters.entityType ? [filters.entityType] : [])
|
||||
draftAction.value = filters.action ?? ''
|
||||
draftPerformedBy.value = filters.performedBy ?? ''
|
||||
filterDrawerOpen.value = true
|
||||
}
|
||||
|
||||
// Bascule un type d'entite dans le brouillon (multi-selection). Les valeurs
|
||||
// sont uniques par construction (v-for sur entityTypeOptions), pas besoin de Set.
|
||||
function toggleEntity(value: string, selected: boolean): void {
|
||||
draftEntityTypes.value = selected
|
||||
? [...draftEntityTypes.value, value]
|
||||
: draftEntityTypes.value.filter(v => v !== value)
|
||||
}
|
||||
|
||||
// "Reinitialiser" : vide le brouillon ET les filtres actifs, puis recharge.
|
||||
// La remise a zero s'applique immediatement (la table revient a la liste
|
||||
// complete) ; le drawer reste ouvert pour montrer le formulaire vide.
|
||||
function resetFilters(): void {
|
||||
draftDateFrom.value = null
|
||||
draftDateTo.value = null
|
||||
draftEntityTypes.value = []
|
||||
draftAction.value = ''
|
||||
draftPerformedBy.value = ''
|
||||
|
||||
async function resetFilters(): Promise<void> {
|
||||
watchersSuspended = true
|
||||
filters.performedAtAfter = undefined
|
||||
filters.performedAtBefore = undefined
|
||||
filters.entityType = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.action = undefined
|
||||
filters.performedBy = undefined
|
||||
filters.page = 1
|
||||
selectedEntityTypes.value = []
|
||||
performedByInput.value = ''
|
||||
actionValue.value = ''
|
||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||
// leur execution avec le flag `true`, puis on libere.
|
||||
await nextTick()
|
||||
watchersSuspended = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||
function applyFilters(): void {
|
||||
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||
filters.performedAtBefore = draftDateTo.value ?? undefined
|
||||
filters.entityType = draftEntityTypes.value.length > 0 ? [...draftEntityTypes.value] : undefined
|
||||
filters.action = draftAction.value === '' ? undefined : draftAction.value
|
||||
filters.performedBy = draftPerformedBy.value.trim() === '' ? undefined : draftPerformedBy.value.trim()
|
||||
filters.page = 1
|
||||
filterDrawerOpen.value = false
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
|
||||
try {
|
||||
const data = await fetchLogsCached({
|
||||
...filters,
|
||||
// Convertit datetime-local (YYYY-MM-DDTHH:MM) en ISO pour l'API.
|
||||
// MalioDateTime fournit une date+heure sans fuseau (heure locale) ;
|
||||
// on la convertit en ISO UTC pour l'API (bornes exactes, intervalle inclusif).
|
||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||
})
|
||||
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
|
||||
if (token !== requestToken) return
|
||||
entries.value = data.member ?? []
|
||||
totalItems.value = data.totalItems ?? 0
|
||||
} catch {
|
||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
||||
} catch (err) {
|
||||
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||
// une RangeError de `toIso` (date invalide) ou une 500 API laissait l'utilisateur
|
||||
// devant une table vide indistinguable d'un filtre a zero resultat.
|
||||
if (token === requestToken) {
|
||||
entries.value = []
|
||||
totalItems.value = 0
|
||||
console.error('[audit-log] loadEntries failed', err)
|
||||
toast.error({
|
||||
title: t('audit.error.title'),
|
||||
message: t('audit.error.message'),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) {
|
||||
@@ -315,14 +363,9 @@ async function loadEntries(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce auto-importe depuis `frontend/shared/utils/debounce.ts` : evite
|
||||
// un refetch a chaque frappe sur le champ texte performedBy (reseau + SQL)
|
||||
// et laisse l'utilisateur finir sa saisie avant de lancer la requete.
|
||||
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||
|
||||
function toIso(localDateTime: string): string {
|
||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||
// laisse le navigateur generer l'ISO via Date().
|
||||
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||
return new Date(localDateTime).toISOString()
|
||||
}
|
||||
|
||||
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||
watch(selectedEntityTypes, values => {
|
||||
if (watchersSuspended) return
|
||||
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync MalioSelect action -> filters.action.
|
||||
watch(actionValue, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.action = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||
// coalescer si plusieurs watchers tirent en meme temps.
|
||||
watch(performedByInput, value => {
|
||||
if (watchersSuspended) return
|
||||
filters.performedBy = value === '' ? undefined : value
|
||||
filters.page = 1
|
||||
debouncedReload()
|
||||
})
|
||||
|
||||
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||
// reset de la pagination a la page 1.
|
||||
watch(
|
||||
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||
() => {
|
||||
if (watchersSuspended) return
|
||||
filters.page = 1
|
||||
loadEntries()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
// Charge les entity types en parallele de la liste principale : un
|
||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
||||
try {
|
||||
entityTypes.value = await fetchEntityTypes()
|
||||
} catch {
|
||||
entityTypes.value = []
|
||||
}
|
||||
await loadEntries()
|
||||
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||
await Promise.all([
|
||||
fetchEntityTypes()
|
||||
.then(types => { entityTypes.value = types })
|
||||
.catch(() => { entityTypes.value = [] }),
|
||||
loadEntries(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.roles.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.roles.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('core.roles.manage')"
|
||||
:label="t('admin.roles.newRole')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des roles -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="roleItems"
|
||||
:total-items="roles.length"
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.users.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||
|
||||
<!-- Table des utilisateurs -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="userItems"
|
||||
:total-items="users.length"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
|
||||
const { resetModules } = useModules()
|
||||
const { resetCurrentSite } = useCurrentSite()
|
||||
const { resetAuditLog } = useAuditLog()
|
||||
const { resetCategoriesAdmin } = useCategoriesAdmin()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -27,6 +28,7 @@ onMounted(async () => {
|
||||
resetModules()
|
||||
resetCurrentSite()
|
||||
resetAuditLog()
|
||||
resetCategoriesAdmin()
|
||||
await navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer
|
||||
:model-value="modelValue"
|
||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
||||
drawer-class="w-full max-w-lg"
|
||||
header-class="border-b border-black"
|
||||
footer-class="justify-between border-t border-black p-6"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">
|
||||
{{ isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="t('admin.sites.form.name')"
|
||||
@@ -70,30 +76,35 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Footer fixe : depuis la 1.7.1 le slot #footer est un frere du body
|
||||
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
v-if="isEditMode"
|
||||
:label="t('common.delete')"
|
||||
variant="danger"
|
||||
icon-name="mdi:delete-outline"
|
||||
icon-position="left"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
<MalioButton
|
||||
v-else
|
||||
:label="t('common.cancel')"
|
||||
variant="tertiary"
|
||||
button-class="w-[150px]"
|
||||
@click="emit('update:modelValue', false)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('common.save')"
|
||||
variant="primary"
|
||||
button-class="w-[150px]"
|
||||
:disabled="saving || !isValidHex"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</template>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tete -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ t('admin.sites.title') }}
|
||||
</h1>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</div>
|
||||
<PageHeader>
|
||||
{{ t('admin.sites.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="can('sites.manage')"
|
||||
:label="t('admin.sites.newSite')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="openCreateDrawer"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Table des sites -->
|
||||
<MalioDataTable
|
||||
class="mt-6"
|
||||
:columns="columns"
|
||||
:items="siteItems"
|
||||
:total-items="sites.length"
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.5.0",
|
||||
"@malio/layer-ui": "^1.7.1",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<!-- Entete de page standard : source unique du style des titres.
|
||||
Slot par defaut = texte du titre, slot #actions = boutons a droite. -->
|
||||
<div class="mb-[44px] flex items-center justify-between gap-4">
|
||||
<h1 class="text-[32px] font-semibold text-primary-500">
|
||||
<slot/>
|
||||
</h1>
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchOptions , FetchError } from 'ofetch'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
|
||||
|
||||
function extractErrorMessage(error: unknown, responseData?: unknown): string {
|
||||
const data = responseData ?? (error as FetchError)?.data
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string) ||
|
||||
(record.detail as string) ||
|
||||
(record.message as string) ||
|
||||
(record.error as string) ||
|
||||
(record.title as string) ||
|
||||
(record['hydra:title'] as string) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const msg = extractApiErrorMessage(data)
|
||||
if (msg) return msg
|
||||
return (error as FetchError)?.message ?? 'Erreur inconnue.'
|
||||
}
|
||||
|
||||
|
||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
||||
module: string
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupement de permissions par module pour l'affichage en accordeon.
|
||||
* Construit cote consommateur a partir de la liste plate /api/permissions.
|
||||
*/
|
||||
export interface PermissionModule {
|
||||
module: string
|
||||
permissions: Permission[]
|
||||
}
|
||||
|
||||
@@ -31,3 +31,62 @@ export interface HydraCollection<T> {
|
||||
export function extractHydraMembers<T>(collection: HydraCollection<T>): T[] {
|
||||
return collection.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
|
||||
* pointe le champ concerne, `message` est le libelle a afficher.
|
||||
*/
|
||||
export interface ApiViolation {
|
||||
propertyPath: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
|
||||
* les deux formats de negociation (`violations` ou `hydra:violations`) et
|
||||
* renvoie un tableau vide si le payload n'en contient pas d'exploitables.
|
||||
*
|
||||
* Utilise par useCategoryForm et tout futur composable de formulaire qui
|
||||
* doit mapper les violations serveur sur ses champs.
|
||||
*/
|
||||
export function extractApiViolations(data: unknown): ApiViolation[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const record = data as Record<string, unknown>
|
||||
const raw = record.violations ?? record['hydra:violations']
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ApiViolation[] = []
|
||||
for (const v of raw) {
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const obj = v as Record<string, unknown>
|
||||
out.push({
|
||||
propertyPath: String(obj.propertyPath ?? ''),
|
||||
message: String(obj.message ?? ''),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un message d'erreur lisible depuis un payload Hydra / JSON
|
||||
* d'erreur API Platform. Essaie les champs courants dans l'ordre :
|
||||
* `hydra:description` → `detail` → `description` → `message` → `error` →
|
||||
* `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
|
||||
*
|
||||
* Si `data` est une string, la renvoie telle quelle (cas des erreurs
|
||||
* Symfony en text/plain ou des messages bruts).
|
||||
*/
|
||||
export function extractApiErrorMessage(data: unknown): string {
|
||||
if (typeof data === 'string') return data
|
||||
if (!data || typeof data !== 'object') return ''
|
||||
const record = data as Record<string, unknown>
|
||||
return (
|
||||
(record['hydra:description'] as string)
|
||||
?? (record.detail as string)
|
||||
?? (record.description as string)
|
||||
?? (record.message as string)
|
||||
?? (record.error as string)
|
||||
?? (record.title as string)
|
||||
?? (record['hydra:title'] as string)
|
||||
?? ''
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
|
||||
|
||||
/**
|
||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
||||
|
||||
Reference in New Issue
Block a user