Compare commits

...

5 Commits

Author SHA1 Message Date
Matthieu df8ea4d0f0 fix(ci) : recreer l'index partiel uq_category_name_type_active apres schema:update
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m21s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m4s
doctrine:schema:update --force drop l'index unique partiel cree par la
migration M0 Catalog (LOWER(name), category_type_id) WHERE deleted_at IS NULL :
Doctrine ORM ne sait pas exprimer les index fonctionnels partiels via les
mappings, donc le voit comme orphelin.

Resultat : en CI les tests CategoryUniqueTest::testDuplicateName* attendent
un 409 (collision) et recoivent 201 — l'index unique n'existant plus, le
doublon passe.

Aligne le step CI sur la cible makefile test-db-setup qui recreait deja
l'index manuellement apres schema:update.
2026-05-28 15:37:33 +02:00
Matthieu 9a7ae577f4 chore(commercial) : link M1 specs to Lesstime taskgroup #23 2026-05-28 15:35:14 +02:00
Matthieu d62629f754 docs(commercial) : add M1 répertoire clients specs (front + back)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m1s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m22s
2026-05-28 15:11:39 +02:00
gitea-actions 9613857650 chore: bump version to v0.1.49
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m8s
2026-05-28 12:29:43 +00:00
tristan 2a0918bbfe [#ERP-42] Mettre à jour la lib Malio UI (#16)
Auto Tag Develop / tag (push) Successful in 9s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-05-28 12:27:33 +00:00
21 changed files with 1800 additions and 396 deletions
+8
View File
@@ -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
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.48'
app.version: '0.1.49'
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
---
# === IDENTITÉ ===
module: M1
nom: "Répertoire clients"
ecran: repertoire-clients
owner_spec: Matthieu
backup_spec: Tristan
version: V0
date_redaction: 2026-05-28
# === LIENS ===
maquette_figma: "https://www.figma.com/design/jRYgT0T9c03VsEbjGhCwwS/Composants---Design-System?node-id=1132-31898"
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, RG-1.14, RG-1.15, RG-1.16, RG-1.17, RG-1.18, RG-1.19, RG-1.20, RG-1.21]
roles: [Admin, Bureau, Compta, Commerciale, Usine]
lien_spec_back: ./spec-back.md
# === VALIDATION CLIENT #1 ===
client_validation_1:
statut: validee
date: 2026-05-22
canal: ecrit
valide_par: "Matthieu (CP MALIO) — validation implicite, périmètre projet"
resume: "Module 1 — Répertoire clients. Page d'entrée Commercial. Datatable + 3 écrans (Ajouter / Consulter / Modifier). Création par onglets : Information / Contact / Adresse / Comptabilité (Transport, Statistiques, Rapports, Échanges = placeholders blancs)."
trace_archivee: "uploads/4a1b026f-M1-reportoire-clients.docx (V0 d'origine .docx)"
# === LIEN LESSTIME ===
lesstime_taskgroup_id: 23
lesstime_project_id: 6
statut_global: en_dev
---
# Module 1 — Répertoire clients (V0 front)
> **Origine** : spec front V0 livrée le 22/05/2026 (`M1-reportoire-clients.docx`). Restitution Markdown pour intégration au workflow MALIO. Le contenu original n'est pas modifié — toute précision et toute décision (en particulier côté back) vit dans [`spec-back.md`](./spec-back.md).
## But
Permettre aux utilisateurs Starseed (selon rôle) de gérer le **répertoire des clients** de l'organisation : consultation, création, modification, archivage. Cette page est la **porte d'entrée du module Commercial**.
## Accès
- **Depuis** : menu principal → section **Commercial** → entrée « Répertoire clients »
- **Rôles autorisés** :
| Rôle | Consultation | Création / Modification | Archivage |
|---|---|---|---|
| **Admin** | ✅ Tout | ✅ Tout | ✅ |
| **Bureau** | ✅ Tout | ✅ Tout sauf onglet Comptabilité | ❌ |
| **Compta** | ✅ Tout | ❌ (lecture seule) | ❌ |
| **Commerciale** | ✅ Tout sauf Comptabilité | ✅ Tout sauf Comptabilité | ❌ |
| **Usine** | ❌ | ❌ | ❌ |
> **⚠ Décision validée par Tristan (28/05/2026)** : le rôle **Compta est en lecture seule** sur l'ensemble du module clients, y compris l'onglet Comptabilité. Le tableau d'origine du `.docx` indiquait « Compta = Ajout / Modification : Onglet Comptabilité uniquement » — cette ligne est **invalidée** par cette spec. Si un besoin métier d'édition apparaît plus tard, une décision archi dédiée sera prise (cf. HP-X de [`spec-back.md`](./spec-back.md)).
## Navigation
L'écran est la page d'entrée du module **Commercial**. Titre : « **Répertoire clients** ».
- Affichage principal : un **datatable** listant tous les clients **actifs** de l'organisation (les clients archivés sont masqués par défaut — filtre UI dédié pour les voir).
- **Clic sur une ligne** → bascule sur l'écran **Consultation client** (page dédiée, pas un drawer — cf. maquette Figma).
- **Bouton « + Ajouter »** (en haut à droite) → bascule sur l'écran **Ajouter un client**.
- **Bouton « Exporter »** (en haut à droite) → télécharge un **fichier XLSX** des clients **affichés** (cf. filtre actif). Format détaillé dans [`spec-back.md` § Export](./spec-back.md).
## Datatable du Répertoire
Composant : `<MalioDataTable>`. Colonnes (à raffiner avec Tristan en revue maquette) :
| Colonne | Source | Tri |
|---|---|---|
| **Nom entreprise** | `client.companyName` | ASC par défaut |
| **Contact principal** | `firstName + lastName` | Oui |
| **Téléphone principal** | `phonePrimary` (formaté `XX XX XX XX XX`) | Non |
| **Email principal** | `email` | Oui |
| **Catégories** | liste des codes catégories séparés par `,` | Non |
| **Site(s)** | sites rattachés à au moins une adresse (badges colorés) | Non |
> **Filtre archivés** : toggle UI en haut du datatable. Désactivé par défaut. État local (pas dans l'URL — cf. règle ABSOLUE Starseed n°6).
> **Pagination** : front via `<MalioDataTable>` (volumétrie cible faible — quelques centaines). Tri serveur `companyName ASC` par défaut.
## Écran « Ajouter un client »
Création par **onglets successifs avec validation incrémentale** : pour pouvoir passer à l'onglet suivant, il faut avoir validé l'onglet en cours. **Une fois un onglet validé, on passe automatiquement au suivant**, et les champs de l'onglet validé passent en lecture seule + bouton « Valider » désactivé (disabled).
### Formulaire principal (pré-onglets)
C'est le 1er bloc à remplir. Sans validation de ce formulaire, les onglets ne sont pas accessibles.
| Champ | Type composant | Obligatoire | Règle |
|---|---|---|---|
| **Nom du client (Entreprise)** | `<MalioInputText>` | Oui | RG-1.18 (normalisation UPPERCASE serveur) |
| **Nom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Prénom du contact principal** | `<MalioInputText>` | Conditionnel | RG-1.01 + RG-1.19 (Capitalize) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` de l'API ; M2M Client ↔ Category |
| **Téléphone principal** | `<MalioInputText>` (masque tel) | Oui | RG-1.02 + RG-1.20 (format `XX XX XX XX XX`) |
| **Téléphone secondaire** | `<MalioInputText>` (masque tel) | Non | Apparaît au clic sur le bouton `+` (RG-1.02). Max 2 — bouton `+` disparaît une fois rempli. |
| **Email** | `<MalioInputText>` type email | Oui | RG-1.21 (lowercase) |
| **Distributeur / Courtier** | `<MalioSelect>` | Non | Valeurs : `Dépend du distributeur` / `Dépend du courtier` / `Aucun`. RG-1.03 conditionne les 2 champs suivants. |
| **Nom du distributeur** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du distributeur ». Liste = clients ayant ≥ 1 catégorie de type `DISTRIBUTEUR`. RG-1.03. |
| **Nom du courtier** | `<MalioSelect>` | Conditionnel | Visible si « Dépend du courtier ». Liste = clients ayant ≥ 1 catégorie de type `COURTIER`. RG-1.03. |
| **Prestation de triage** | `<MalioCheckbox>` | Non | — |
**Action** : « Valider » (`<MalioButton>`) → POST `/api/clients` ([`spec-back.md` § 4.3](./spec-back.md)). Si succès, on passe automatiquement à l'onglet « Information ».
### Onglet « Information »
Saisir les informations de l'entreprise.
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Description** | `<MalioInputTextArea>` | Conditionnel | RG-1.04 (obligatoire pour rôle Commerciale) |
| **Concurrents** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Date de création** (de l'entreprise) | `<input type="date">` (exception Malio — pas de composant date couvert) | Conditionnel | RG-1.04 |
| **Nombre de salariés** | `<MalioInputNumber>` | Conditionnel | RG-1.04 |
| **CA €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
| **Dirigeant** | `<MalioInputText>` | Conditionnel | RG-1.04 |
| **Résultat €** | `<MalioInputAmount>` | Conditionnel | RG-1.04 |
**Action** : « Valider » → PATCH partiel `/api/clients/{id}` (groupe `client:write:information`).
### Onglet « Contact »
Saisir un ou plusieurs contacts associés au client. Le 1er bloc est **pré-rempli** depuis les champs du formulaire principal (Nom, Prénom, Téléphone, Email — édition autorisée).
**Bloc Contact** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Nom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
| **Prénom** | `<MalioInputText>` | Conditionnel | RG-1.05 + RG-1.19 (Capitalize) |
| **Fonction** | `<MalioInputText>` | Non | — |
| **Téléphone** (x1, +1 possible) | `<MalioInputText>` | Non | RG-1.20 (format) |
| **Email** | `<MalioInputText>` type email | Non | RG-1.21 (lowercase) |
**RG-1.14 (renforcement validée par Tristan le 28/05)** : **au moins 1 bloc Contact valide** (au moins Nom OU Prénom rempli) est obligatoire pour valider l'onglet. Donc l'onglet Contact ne peut pas être finalisé vide.
**Actions** :
- « + Nouveau contact » : ajoute un bloc. Bouton **désactivé tant que le bloc précédent n'a pas Prénom OU Nom rempli** (RG-1.05).
- « Supprimer » (icône) sur un bloc : modal de confirmation (`<MalioButton>` Annuler / Confirmer). Si Oui → suppression du bloc.
- « Valider » → PATCH `/api/clients/{id}/contacts` (création/mise à jour de la collection).
### Onglet « Adresse »
Saisir une ou plusieurs adresses du client, rattachées à un ou plusieurs sites Starseed (Châtellerault 86 / Saint-Jean 17 / Pommevic 82) et à des contacts.
**Bloc Adresse** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Prospect** | `<MalioCheckbox>` | Non | RG-1.06 — masque Adresse de livraison + Facturation si coché |
| **Adresse de livraison** | `<MalioCheckbox>` | Non | RG-1.07 — masque Prospect si coché |
| **Facturation** | `<MalioCheckbox>` | Non | RG-1.08 — masque Prospect si coché ; affiche le champ Email (RG-1.11) |
| **Catégorie** | `<MalioSelectCheckbox>` (multi) | Oui | Liste des `Category` |
| **Pays** | `<MalioSelect>` | Oui | Préremplie « France » |
| **Code postal** | `<MalioInputText>` (masque numérique) | Oui | RG-1.09 — déclenche autocomplete ville via BAN |
| **Ville** | `<MalioSelect>` | Oui | RG-1.09 — alimentée par api-adresse.data.gouv.fr suivant le CP |
| **Adresse** | `<MalioInputText>` (saisie assistée) | Oui | RG-1.09 — autocomplete BAN |
| **Adresse complémentaire** | `<MalioInputText>` | Non | — |
| **Sites Starseed** | `<MalioSelectCheckbox>` (multi-checkbox 86 / 17 / 82) | Oui | RG-1.10 — ≥ 1 site obligatoire |
| **Contact(s) rattaché(s)** | `<MalioSelectCheckbox>` (multi) | Non | Liste = blocs Contact saisis dans l'onglet Contact |
| **Email (facturation)** | `<MalioInputText>` type email | Conditionnel | RG-1.11 — visible/obligatoire uniquement si « Facturation » coché |
**Actions** :
- « + Nouvelle Adresse » : ajoute un bloc identique.
- « Supprimer » : modal de confirmation puis suppression.
- « Valider » → PATCH `/api/clients/{id}/addresses`.
### Onglet « Transport »
🚧 **Placeholder blanc au M1.** Frame vide. Aucun champ. Aucun bouton de validation. L'utilisateur passe automatiquement à l'onglet suivant. **Pas de mention « En cours »** — c'est juste blanc (décision Tristan 28/05).
### Onglet « Comptabilité »
**Accessible uniquement aux rôles avec `commercial.clients.accounting.manage`** (Admin seul au M1). Bureau et Commerciale ne voient pas l'onglet. Compta voit l'onglet **en lecture seule** (cf. décision Compta lecture seule).
**Champs comptables** :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **SIREN** | `<MalioInputText>` (masque 9 chiffres) | Oui | RG-1.15 (unicité) |
| **Numéro de compte** | `<MalioInputText>` | Oui | — |
| **Mode de TVA** | `<MalioSelect>` | Oui | Liste depuis `/api/tva_modes` |
| **N° de TVA** | `<MalioInputText>` | Oui | — |
| **Délai de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_delays` |
| **Type de règlement** | `<MalioSelect>` | Oui | Liste depuis `/api/payment_types` |
| **Banque** | `<MalioSelect>` | Conditionnel | RG-1.12 — visible et obligatoire **si** Type de règlement = `VIREMENT`. Liste depuis `/api/banks`. |
**Bloc RIB** (0..n blocs, présence obligatoire conditionnée par RG-1.13) :
| Champ | Type | Obligatoire | Règle |
|---|---|---|---|
| **Libellé** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 |
| **BIC** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
| **IBAN** | `<MalioInputText>` | Oui (si LCR) | RG-1.13 — `#[AuditIgnore]` (champ sensible) |
**Actions** :
- « + RIB » : ajoute un bloc.
- « Supprimer » (icône) : modal de confirmation.
- « Valider » → PATCH `/api/clients/{id}/accounting`.
### Onglets « Statistiques » / « Rapports » / « Échanges »
🚧 **Placeholders blancs au M1.** Mêmes règles que Transport (frames vides, pas de validation).
## Écran « Consultation client »
Tous les champs en **lecture seule**. Layout identique à l'écran Ajouter mais sans bouton « Valider », sans bouton `+` pour ajouter des blocs Contact / Adresse / RIB.
- **Flèche retour** (à gauche) → revient au Répertoire.
- **Bouton « Modifier »** (à droite, visible si l'utilisateur a la permission `commercial.clients.manage`) → bascule sur l'écran Modification.
- **Bouton « Archiver »** (à droite, visible **uniquement pour Admin** via permission `commercial.clients.archive`) → ouvre une modal de confirmation, puis PATCH `/api/clients/{id}` `{ "isArchived": true }`. Le client passe en archivé (cf. flag `is_archived`).
> Le client archivé peut être restauré (`isArchived: false`) — bouton « Restaurer » remplace « Archiver » dans la consultation d'un archivé. Décision validée Tristan 28/05.
### Onglets affichés en consultation
Mêmes onglets qu'en création, **plus** les 4 placeholders blancs. L'utilisateur navigue librement entre les onglets (pas de séquence forcée en consultation).
## Écran « Modification client »
Comportement identique à l'écran Ajouter sauf :
- **Pas de formulaire principal** (les champs principaux sont édités via les onglets correspondants).
- Les champs sont **pré-remplis** avec les valeurs actuelles.
- **Validation par onglet** : on peut modifier UN onglet sans toucher aux autres (PATCH partiel).
- Les onglets pour lesquels l'utilisateur n'a **pas** la permission `manage` restent en lecture seule (pas de bouton Valider, pas d'icône suppression de bloc).
- Les onglets placeholders restent inaccessibles à l'édition (blancs).
## Composants UI à utiliser (`@malio/layer-ui`)
- **Datatable** : `<MalioDataTable>` (Répertoire)
- **Input texte** : `<MalioInputText>`
- **Input numérique** : `<MalioInputNumber>`
- **Input montant** : `<MalioInputAmount>` (CA, Résultat)
- **TextArea** : `<MalioInputTextArea>` (Description)
- **Select simple** : `<MalioSelect>` (Pays, Ville, distributeur/courtier, refs comptables)
- **Select multi (cases à cocher)** : `<MalioSelectCheckbox>` (Catégorie, Sites, Contacts rattachés)
- **Checkbox** : `<MalioCheckbox>` (Prospect, Adresse livraison, Facturation, Prestation de triage)
- **Bouton** : `<MalioButton>`, `<MalioButtonIcon>`
- **Toasts** : standards via `useApi()`
**Exceptions autorisées** (à commenter `// TODO migrer quand Malio couvre`) :
- `<input type="date">` pour « Date de création » (composant `MalioDate` non couvert)
- Modal de confirmation : composant à confirmer côté équipe front (probablement `<MalioModal>` ou un wrapper à créer dans `frontend/shared/`)
## Règles de formatage et normalisation
Le serveur normalise systématiquement (cf. RG-1.18 à RG-1.21 dans [`spec-back.md`](./spec-back.md)) :
| Champ | Normalisation serveur | Affichage front |
|---|---|---|
| Nom entreprise (`companyName`) | UPPERCASE intégral | UPPERCASE |
| Nom + Prénom contact | Capitalize (1ère lettre majuscule + reste minuscule) | identique |
| Téléphone (`phonePrimary`, `phoneSecondary`, contact phones) | Chiffres uniquement en BDD | Formaté `XX XX XX XX XX` à l'affichage (filter Vue) |
| Email | lowercase intégral | identique |
> **Le front ne fait pas la normalisation** — il envoie la valeur saisie, le serveur normalise puis renvoie la valeur normalisée. L'UI affiche immédiatement la valeur normalisée renvoyée par l'API. Cohérent avec le pattern `useApi()`.
## API adresse postale
Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse.data.gouv.fr** (Base Adresse Nationale, gratuite, française).
- Composable dédié `useAddressAutocomplete()` (à créer en M1).
- Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back.
- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse.
- Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `<MalioInputText>` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre.
## Points laissés ouverts par la V0 (résolus côté back)
| # | Zone d'ombre V0 | Résolution (cf. `spec-back.md`) |
|---|---|---|
| 1 | Catégorie en multi-select non clarifiée (1 ou n par client) | **M2M `client_category`** validée. CategoryType seedé avec `DISTRIBUTEUR`, `COURTIER`, `SECTEUR`, `AUTRE` (HP-3 du M0 levé). |
| 2 | Distributeur / Courtier : liste de quoi ? | **Auto-référence Client** via 2 FK nullables `distributor_id` et `broker_id` (cf. RG-1.03). Une seule des deux est remplie à la fois. |
| 3 | Onglet « Comptabilité » : qui édite ? | **Admin uniquement au M1.** Compta lecture seule (décision validée par Tristan 28/05). Bureau / Commerciale ne voient pas l'onglet. |
| 4 | Workflow par onglet | **Sauvegarde incrémentale**. POST formulaire principal crée le `Client` (status implicite « actif »). Chaque onglet validé = PATCH partiel par groupe de sérialisation dédié. Pas d'état « draft ». |
| 5 | Onglets « À venir » | **Placeholders blancs** (frames vides, pas de message). Ré-activables sans rebuild quand les modules associés arriveront. |
| 6 | Archive vs soft delete | **Flag `is_archived` séparé de `deleted_at`**. Archive ≠ delete : un client archivé est masqué par défaut mais reste en BDD éditable (Admin seul). Filtres UI distincts. Soft delete = HP M2. |
| 7 | Unicité métier | **SIREN, Nom entreprise, Email principal — tous uniques parmi non-archivés.** Index partiels Postgres. Tentative de doublon → 409 Conflict. |
| 8 | Téléphones (max 2) | **2 colonnes plates** `phone_primary` + `phone_secondary`. Pas de table séparée. |
| 9 | API code postal | **api-adresse.data.gouv.fr** (BAN). Appel direct front via composable dédié. Cas dégradé : saisie libre + toast. |
| 10 | Référentiels comptables | **4 entités CRUD-ables** (`TvaMode`, `PaymentDelay`, `PaymentType`, `Bank`) seedées au M1, CRUD admin futur (HP-M2). |
| 11 | Format de l'export | **XLSX uniquement** au M1. CSV à étudier en HP. |
---
## 📦 Tickets Lesstime générés
**TaskGroup Lesstime** : à créer — `M1 — Répertoire clients` (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).
+10 -2
View File
@@ -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>
+7
View File
@@ -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é",
@@ -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>
+46 -44
View File
@@ -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()
+185 -179
View File
@@ -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>
+12 -14
View File
@@ -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 -7
View File
@@ -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"
+2 -2
View File
@@ -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>
@@ -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>
+12 -14
View File
@@ -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"
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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>
+9
View File
@@ -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[]
}