Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e611f4c65a | |||
| 5c4bc32827 | |||
| 256b8d4ff2 | |||
| 0dfdaf3300 | |||
| 0a5ac61957 | |||
| 7c2e570fa0 | |||
| 43d80df1e1 | |||
| 5db644d22e |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.39'
|
app.version: '0.1.40'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
# === IDENTITÉ ===
|
||||||
|
module: M0
|
||||||
|
nom: "Gestion des catégories"
|
||||||
|
ecran: gestion-categories
|
||||||
|
owner_spec: Matthieu
|
||||||
|
backup_spec: Tristan
|
||||||
|
version: V0
|
||||||
|
date_redaction: 2026-05-22
|
||||||
|
|
||||||
|
# === LIENS ===
|
||||||
|
maquette_figma: null # pas de Figma — UI admin standard
|
||||||
|
regles_metier: [RG-1.01, RG-1.02, RG-1.03, RG-1.04, RG-1.05, RG-1.06, RG-1.07, RG-1.08, RG-1.09, RG-1.10, RG-1.11, RG-1.12, RG-1.13]
|
||||||
|
roles: [Admin, Bureau, Compta, Commerciale, Usine]
|
||||||
|
lien_spec_back: ./spec-back.md
|
||||||
|
|
||||||
|
# === VALIDATION CLIENT #1 ===
|
||||||
|
client_validation_1:
|
||||||
|
statut: validee # V0 client validée le 22/05/2026
|
||||||
|
date: 2026-05-22
|
||||||
|
canal: ecrit
|
||||||
|
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
|
||||||
|
resume: "Module 0 — Gestion des catégories. Page admin (datatable + drawer). 2 champs (Nom + Type), 3 actions (Ajouter / Consulter / Modifier). Admin only."
|
||||||
|
trace_archivee: "uploads/c4ebb6b4-M0categories.docx (V0 d'origine .docx) — restitué ci-dessous en Markdown."
|
||||||
|
|
||||||
|
# === LIEN LESSTIME ===
|
||||||
|
lesstime_taskgroup_id: 22
|
||||||
|
lesstime_project_id: 6 # ERP / Starseed
|
||||||
|
statut_global: en_dev # tickets créés en backlog Lesstime le 2026-05-26
|
||||||
|
---
|
||||||
|
|
||||||
|
# Module 0 — Gestion des catégories (V0 front)
|
||||||
|
|
||||||
|
> **Origine** : spec front V0 livrée le 22/05/2026 (`c4ebb6b4-M0categories.docx` + `f665acfb-M0categoriesV0.pdf`). Restitution Markdown fidèle pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute reformulation et précision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
|
||||||
|
|
||||||
|
## But
|
||||||
|
|
||||||
|
Permettre à un administrateur Starseed de gérer un référentiel de **catégories** depuis l'interface admin du logiciel. Ces catégories seront utilisées plus tard pour classifier les tiers (clients, fournisseurs, prestataires).
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
- **Depuis** : menu principal → **Administration** → entrée « Gestion des catégories »
|
||||||
|
- **Rôles autorisés** : **Admin uniquement** (Bureau / Compta / Commerciale / Usine n'ont **aucun** accès, ni lecture ni écriture).
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
L'écran est la page d'entrée du Module **Administration**. Titre de la page : « **Gestion des catégories** ».
|
||||||
|
|
||||||
|
- Affichage principal : un **datatable** listant toutes les catégories existantes.
|
||||||
|
- **Clic sur une ligne** → ouverture d'un **drawer** latéral en mode **consultation / modification** (cf. § Action « Consulter »).
|
||||||
|
- **Bouton « + Ajouter »** (en haut à droite du datatable) → ouverture d'un **drawer** en mode **création** (cf. § Action « Ajouter »).
|
||||||
|
- Pas d'onglet, pas de pagination explicite (volumétrie cible faible).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Action | Déclencheur | Comportement |
|
||||||
|
|---|---|---|
|
||||||
|
| **Ajouter** | Clic sur le bouton « + Ajouter » | Ouvre le drawer en mode création, formulaire vide. Validation → POST → la catégorie apparaît dans le datatable. |
|
||||||
|
| **Consulter** | Clic sur une ligne du datatable | Ouvre le drawer avec les champs pré-remplis en lecture (et passage en édition si l'utilisateur modifie un champ). |
|
||||||
|
| **Modifier** | Modification d'un champ dans le drawer ouvert en consultation | Validation → PATCH → la ligne du datatable se met à jour. |
|
||||||
|
|
||||||
|
> **Note V0** : la **suppression** n'était pas mentionnée dans la V0 client. Côté workflow MALIO, suite à la revue back (cf. `spec-back.md` § Q3), un soft delete est ajouté (corbeille logique). L'UI peut intégrer ce point lors d'une V1 — au M0 le bouton « Supprimer » n'est pas obligatoire, mais doit être facilement ajoutable.
|
||||||
|
|
||||||
|
## Formulaire — Champs
|
||||||
|
|
||||||
|
Le formulaire (drawer) contient **2 champs**, tous deux obligatoires :
|
||||||
|
|
||||||
|
| Champ | Type | Obligatoire | Contenu / valeur par défaut | Règle |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Nom** | Texte libre | **Oui** | vide à la création | Pas de règle métier détaillée en V0. Détails côté back : RG-1.02 / RG-1.03 / RG-1.04 (obligatoire, trim, longueur 2–120). |
|
||||||
|
| **Type de catégorie** | Select | **Oui** | vide à la création | Le contenu du Select n'était pas précisé en V0. Décision back : entité de référence `CategoryType` séparée (RG-1.05 / RG-1.06). Le référentiel sera alimenté plus tard (cf. HP-1 dans `spec-back.md`). |
|
||||||
|
|
||||||
|
> **Note V0** : la V0 ne précisait ni si le `Type de catégorie` est un enum hardcodé ni si c'est une autre entité. Décision tranchée côté back avant découpe en tickets : **entité de référence** (`category_types`), table créée vide au M0.
|
||||||
|
|
||||||
|
## Permissions par rôle
|
||||||
|
|
||||||
|
| Rôle | Vue (`GET`) | Création (`POST`) | Édition (`PATCH`) | Suppression (`DELETE`) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Admin** | ✅ | ✅ | ✅ | ✅ (soft delete — ajout post-V0) |
|
||||||
|
| Bureau | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Compta | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Commerciale | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
| Usine | ❌ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
→ Les rôles non-Admin ne voient **pas** l'entrée de menu et reçoivent **403** sur toute requête vers les endpoints `/api/categories/*` (cf. RG-1.01 dans `spec-back.md`).
|
||||||
|
|
||||||
|
## Composants UI à utiliser (Starseed / `@malio/layer-ui`)
|
||||||
|
|
||||||
|
- **Datatable** : `<MalioDataTable>` (avec colonnes `Nom` + `Type` + actions, tri par défaut sur Nom).
|
||||||
|
- **Drawer** : drawer latéral standard `@malio/layer-ui` (à confirmer côté front avec le composant exact).
|
||||||
|
- **Input texte** : `<MalioInputText>` pour le champ Nom.
|
||||||
|
- **Select** : `<MalioSelect>` pour le champ Type de catégorie, alimenté par `GET /api/category_types`.
|
||||||
|
- **Bouton** : `<MalioButton>` (« + Ajouter », « Enregistrer », « Annuler »).
|
||||||
|
- **Toasts succès / erreur** : standards via `useApi()`.
|
||||||
|
|
||||||
|
## Points laissés ouverts par la V0 (résolus côté back)
|
||||||
|
|
||||||
|
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Suppression non mentionnée | **Soft delete** ajouté (RG-1.12 + RG-1.13). UI peut ajouter le bouton plus tard. |
|
||||||
|
| 2 | Unicité du nom non précisée | **Unicité sur `(name, type)` case-insensitive**, parmi non-soft-deleted (RG-1.07). |
|
||||||
|
| 3 | Nature du `Type de catégorie` (enum vs entité) | **Entité de référence** `CategoryType` (table vide au M0, créée par migration). |
|
||||||
|
| 4 | Volumétrie & pagination | **300 max** → pagination front (`<MalioDataTable>`), pas de pagination serveur. Tri serveur `name ASC` par défaut. |
|
||||||
|
| 5 | Audit / traçabilité | Pattern `#[Auditable]` Starseed standard. Trace dans la table `audit_log` (qui / quoi / quand / diff). **Pas** de colonnes `created_by` / `updated_by` sur l'entité (cohérent avec User / Role dans Starseed). Historique consultable via `/api/audit-log?entityType=Category&entityId={id}`. |
|
||||||
|
| 6 | Référencement par d'autres entités | **Aucune FK entrante au M0.** Les modules Tiers (M-Clients / M-Fournisseurs / M-Prestas) ajouteront leur propre `category_id` plus tard. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Tickets Lesstime générés
|
||||||
|
|
||||||
|
**TaskGroup Lesstime** : `#22 — M0 — Gestion des catégories` (projet `ERP / Starseed`, projectId=6)
|
||||||
|
|
||||||
|
> Détail complet, table des tickets et action manuelle dans Lesstime → voir [`spec-back.md § Tickets Lesstime générés`](./spec-back.md#-tickets-lesstime-générés).
|
||||||
@@ -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>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<MalioSidebar
|
<MalioSidebar
|
||||||
v-model="ui.sidebarCollapsed"
|
v-model="ui.sidebarCollapsed"
|
||||||
:sections="translatedSections"
|
:sections="translatedSections"
|
||||||
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||||
>
|
>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
<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">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<SiteSelector v-if="showSiteSelector"/>
|
<SiteSelector v-if="showSiteSelector"/>
|
||||||
<main
|
<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
|
<div
|
||||||
aria-hidden="true"
|
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/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,12 +85,19 @@
|
|||||||
},
|
},
|
||||||
"empty": "Aucune activité enregistrée",
|
"empty": "Aucune activité enregistrée",
|
||||||
"no_results": "Aucun résultat pour ces filtres",
|
"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": {
|
"timeline": {
|
||||||
"empty": "Aucun historique",
|
"empty": "Aucun historique",
|
||||||
"load_more": "Voir plus"
|
"load_more": "Voir plus"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Réinitialiser",
|
||||||
|
"date_range": "Date à date",
|
||||||
"date_from": "Du",
|
"date_from": "Du",
|
||||||
"date_to": "Au",
|
"date_to": "Au",
|
||||||
"entity_type": "Type d'entité",
|
"entity_type": "Type d'entité",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('commercial.title') }}</h1>
|
<PageHeader>{{ $t('commercial.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('commercial.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.roles.editRole') : t('admin.roles.createRole')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
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)"
|
@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 -->
|
<!-- Champs du role -->
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.label"
|
v-model="form.label"
|
||||||
@@ -44,55 +50,51 @@
|
|||||||
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-else-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
:selected-ids="selectedPermissionIds"
|
||||||
:module="group.module"
|
id-prefix="role"
|
||||||
:module-label="group.module"
|
@toggle="handleTogglePermission"
|
||||||
:permissions="group.permissions"
|
@toggle-all="handleToggleAll"
|
||||||
:selected-ids="selectedPermissionIds"
|
/>
|
||||||
@toggle="handleTogglePermission"
|
|
||||||
@toggle-all="handleToggleAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Permission, Role } from '~/shared/types/rbac'
|
import type { Permission, PermissionModule, Role } from '~/shared/types/rbac'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="t('admin.users.drawer.title', { username: user?.username ?? '' })"
|
drawer-class="w-full max-w-[450px]"
|
||||||
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)"
|
@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
|
<!-- Etat d'erreur de chargement des referentiels : bloque la
|
||||||
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
sauvegarde pour empecher un ecrasement silencieux des droits. -->
|
||||||
<div
|
<div
|
||||||
@@ -60,18 +66,14 @@
|
|||||||
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
<div v-if="permissionsByModule.length === 0" class="text-sm text-neutral-400">
|
||||||
{{ t('admin.roles.permissions.noPermissions') }}
|
{{ t('admin.roles.permissions.noPermissions') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4">
|
<PermissionAccordion
|
||||||
<PermissionGroup
|
v-else
|
||||||
v-for="group in permissionsByModule"
|
:groups-by-module="permissionsByModule"
|
||||||
:key="group.module"
|
:selected-ids="selectedDirectPermissionIds"
|
||||||
:module="group.module"
|
id-prefix="direct"
|
||||||
:module-label="group.module"
|
@toggle="handleTogglePermission"
|
||||||
:permissions="group.permissions"
|
@toggle-all="handleToggleAll"
|
||||||
:selected-ids="selectedDirectPermissionIds"
|
/>
|
||||||
@toggle="handleTogglePermission"
|
|
||||||
@toggle-all="handleToggleAll"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
<!-- Section Sites autorises (ticket 2 module Sites) -->
|
||||||
@@ -103,33 +105,32 @@
|
|||||||
<EffectivePermissions :permissions="effectivePermissions" />
|
<EffectivePermissions :permissions="effectivePermissions" />
|
||||||
</div>
|
</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>
|
</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>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import type { Site } from '~/shared/types/sites'
|
||||||
|
|
||||||
interface PermissionModule {
|
|
||||||
module: string
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|||||||
@@ -1,95 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
{{ t('admin.auditLog.title') }}
|
||||||
{{ t('admin.auditLog.title') }}
|
<template #actions>
|
||||||
</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">
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="t('audit.filters.reset')"
|
:label="t('audit.filters.title')"
|
||||||
button-class="text-xs"
|
icon-name="mdi:tune"
|
||||||
@click="resetFilters"
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Tableau -->
|
<!-- Tableau -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-4"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="rows"
|
:items="rows"
|
||||||
:total-items="totalItems"
|
:total-items="totalItems"
|
||||||
@@ -123,12 +50,99 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioDataTable>
|
</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 -->
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
v-model="drawerOpen"
|
v-model="drawerOpen"
|
||||||
:title="drawerTitle"
|
|
||||||
drawer-class="max-w-2xl"
|
drawer-class="max-w-2xl"
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">
|
||||||
|
{{ drawerTitle }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
<div v-if="selectedEntry">
|
<div v-if="selectedEntry">
|
||||||
<AuditLogDetail :entry="selectedEntry" />
|
<AuditLogDetail :entry="selectedEntry" />
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
@@ -149,12 +163,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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'
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
const { fetchLogsCached, fetchEntityTypes } = useAuditLog()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
// Traduit un identifiant `module.Entity` (ex: `core.User`, `sites.Site`) en
|
||||||
// libelle lisible via la cle i18n `audit.entity.<module>_<entity>`. Si aucune
|
// 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') })
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
// Etat des filtres APPLIQUES : pilote `loadEntries`. Local uniquement, JAMAIS
|
||||||
// CLAUDE.md "Tableau : pas de persistance URL").
|
// 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>({
|
const filters = reactive<AuditLogFilters>({
|
||||||
performedAtAfter: undefined,
|
performedAtAfter: undefined,
|
||||||
performedAtBefore: undefined,
|
performedAtBefore: undefined,
|
||||||
@@ -185,26 +203,23 @@ const filters = reactive<AuditLogFilters>({
|
|||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
// Etat BROUILLON du drawer de filtres : edite librement, recopie dans `filters`
|
||||||
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
// uniquement au clic sur "Voir les resultats". Permet d'annuler une saisie en
|
||||||
const selectedEntityTypes = ref<(string | number)[]>([])
|
// 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[]>([])
|
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(() =>
|
const entityTypeOptions = computed(() =>
|
||||||
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
entityTypes.value.map(type => ({ value: type, label: formatEntityType(type) })),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
// Actions : '' = "toutes". Sert d'options aux boutons radio.
|
||||||
// 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>('')
|
|
||||||
const actionOptions = [
|
const actionOptions = [
|
||||||
{ value: '', label: t('audit.filters.all_actions') },
|
{ value: '', label: t('audit.filters.all_actions') },
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
@@ -259,29 +274,55 @@ const isFiltered = computed(() =>
|
|||||||
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
let requestToken = 0
|
let requestToken = 0
|
||||||
|
|
||||||
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
// Ouvre le drawer en recopiant l'etat applique vers le brouillon, pour que la
|
||||||
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
// reouverture reflete les filtres actifs.
|
||||||
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
function openFilters(): void {
|
||||||
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
draftDateFrom.value = filters.performedAtAfter ?? null
|
||||||
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
draftDateTo.value = filters.performedAtBefore ?? null
|
||||||
// explicitement apres la liberation.
|
draftEntityTypes.value = Array.isArray(filters.entityType)
|
||||||
let watchersSuspended = false
|
? [...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.performedAtAfter = undefined
|
||||||
filters.performedAtBefore = undefined
|
filters.performedAtBefore = undefined
|
||||||
filters.entityType = undefined
|
filters.entityType = undefined
|
||||||
filters.performedBy = undefined
|
|
||||||
filters.action = undefined
|
filters.action = undefined
|
||||||
|
filters.performedBy = undefined
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
selectedEntityTypes.value = []
|
loadEntries()
|
||||||
performedByInput.value = ''
|
}
|
||||||
actionValue.value = ''
|
|
||||||
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
// "Voir les resultats" : applique le brouillon, recharge et ferme le drawer.
|
||||||
// leur execution avec le flag `true`, puis on libere.
|
function applyFilters(): void {
|
||||||
await nextTick()
|
filters.performedAtAfter = draftDateFrom.value ?? undefined
|
||||||
watchersSuspended = false
|
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()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +332,8 @@ async function loadEntries(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogsCached({
|
const data = await fetchLogsCached({
|
||||||
...filters,
|
...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,
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||||
})
|
})
|
||||||
@@ -300,13 +342,19 @@ async function loadEntries(): Promise<void> {
|
|||||||
if (token !== requestToken) return
|
if (token !== requestToken) return
|
||||||
entries.value = data.member ?? []
|
entries.value = data.member ?? []
|
||||||
totalItems.value = data.totalItems ?? 0
|
totalItems.value = data.totalItems ?? 0
|
||||||
} catch {
|
} catch (err) {
|
||||||
// En cas d'echec (reseau, 403, 500...), on reset l'etat pour ne pas
|
// useAuditLog appelle useApi avec { toast: false } pour ne pas multiplier
|
||||||
// laisser l'utilisateur croire que les donnees affichees sont a jour.
|
// les toasts, donc c'est ici qu'on fait remonter l'erreur. Sans ce log+toast,
|
||||||
// Le toast d'erreur est deja emis par `useApi()` via useAuditLog.
|
// 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) {
|
if (token === requestToken) {
|
||||||
entries.value = []
|
entries.value = []
|
||||||
totalItems.value = 0
|
totalItems.value = 0
|
||||||
|
console.error('[audit-log] loadEntries failed', err)
|
||||||
|
toast.error({
|
||||||
|
title: t('audit.error.title'),
|
||||||
|
message: t('audit.error.message'),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (token === requestToken) {
|
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 {
|
function toIso(localDateTime: string): string {
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
// MalioDateTime emet une date+heure sans fuseau (heure murale locale) ;
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
// on laisse Date() generer l'ISO UTC correspondant pour l'API.
|
||||||
return new Date(localDateTime).toISOString()
|
return new Date(localDateTime).toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,53 +411,16 @@ function onPerPageChange(value: number): void {
|
|||||||
loadEntries()
|
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 () => {
|
onMounted(async () => {
|
||||||
// Charge les entity types en parallele de la liste principale : un
|
// Charge les entity types ET la liste principale en parallele (TTFD divise
|
||||||
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
// par 2 sur un backend lent). Le `.catch` du premier garantit qu'un echec
|
||||||
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
// de /audit-log-entity-types ne bloque pas l'affichage du tableau —
|
||||||
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
// l'utilisateur perd juste le filtre, pas la page entiere.
|
||||||
try {
|
await Promise.all([
|
||||||
entityTypes.value = await fetchEntityTypes()
|
fetchEntityTypes()
|
||||||
} catch {
|
.then(types => { entityTypes.value = types })
|
||||||
entityTypes.value = []
|
.catch(() => { entityTypes.value = [] }),
|
||||||
}
|
loadEntries(),
|
||||||
await loadEntries()
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ t('admin.roles.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
<template #actions>
|
||||||
{{ t('admin.roles.title') }}
|
<MalioButton
|
||||||
</h1>
|
v-if="can('core.roles.manage')"
|
||||||
<MalioButton
|
:label="t('admin.roles.newRole')"
|
||||||
v-if="can('core.roles.manage')"
|
icon-name="mdi:add-bold"
|
||||||
:label="t('admin.roles.newRole')"
|
icon-position="left"
|
||||||
icon-name="mdi:add-bold"
|
@click="openCreateDrawer"
|
||||||
icon-position="left"
|
/>
|
||||||
@click="openCreateDrawer"
|
</template>
|
||||||
/>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des roles -->
|
<!-- Table des roles -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="roleItems"
|
:items="roleItems"
|
||||||
:total-items="roles.length"
|
:total-items="roles.length"
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>{{ t('admin.users.title') }}</PageHeader>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Table des utilisateurs -->
|
<!-- Table des utilisateurs -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="userItems"
|
:items="userItems"
|
||||||
:total-items="users.length"
|
:total-items="users.length"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
<PageHeader>{{ $t('dashboard.title') }}</PageHeader>
|
||||||
<p class="mt-4 text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
<p class="text-neutral-500">{{ $t('dashboard.welcome') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<MalioDrawer
|
<MalioDrawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
|
|
||||||
drawer-class="w-full max-w-lg"
|
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)"
|
@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
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
:label="t('admin.sites.form.name')"
|
:label="t('admin.sites.form.name')"
|
||||||
@@ -70,30 +76,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</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>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- En-tete -->
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ t('admin.sites.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
<template #actions>
|
||||||
{{ t('admin.sites.title') }}
|
<MalioButton
|
||||||
</h1>
|
v-if="can('sites.manage')"
|
||||||
<MalioButton
|
:label="t('admin.sites.newSite')"
|
||||||
v-if="can('sites.manage')"
|
icon-name="mdi:add-bold"
|
||||||
:label="t('admin.sites.newSite')"
|
icon-position="left"
|
||||||
icon-name="mdi:add-bold"
|
@click="openCreateDrawer"
|
||||||
icon-position="left"
|
/>
|
||||||
@click="openCreateDrawer"
|
</template>
|
||||||
/>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table des sites -->
|
<!-- Table des sites -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="mt-6"
|
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="siteItems"
|
:items="siteItems"
|
||||||
:total-items="sites.length"
|
:total-items="sites.length"
|
||||||
|
|||||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
|||||||
"name": "starseed-frontend",
|
"name": "starseed-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -1866,9 +1866,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@malio/layer-ui": {
|
"node_modules/@malio/layer-ui": {
|
||||||
"version": "1.5.0",
|
"version": "1.7.1",
|
||||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.5.0/layer-ui-1.5.0.tgz",
|
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.1/layer-ui-1.7.1.tgz",
|
||||||
"integrity": "sha512-uVuG8kRakWgpWYQCMUf1LFD+gjx0iRFfNJn/jlqjxiZmZyGZMckcMW2qA9hGZBiheBsTJWw1pRR4ufuyAYPY0A==",
|
"integrity": "sha512-RYMMappWt/fgjD+BM7//h2O6kxD6WH9Fui8hoC29xtKySRQsqD61XKTdR7BRRkpktbxKmV39q/hblyAFBqV5yw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.5.0",
|
"@malio/layer-ui": "^1.7.1",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -43,3 +43,12 @@ export interface EffectivePermission {
|
|||||||
module: string
|
module: string
|
||||||
sources: 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[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user