Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52de07ce23 | |||
| 117c2ff2e3 | |||
| a98ea3df37 | |||
| f1a9b42930 | |||
| 0b4874e94d | |||
| d70925b812 | |||
| f8fc4d6bd9 | |||
| 6ca91cbd3b | |||
| 8865bf51e6 | |||
| d1a980d1c2 | |||
| fdcf8df518 | |||
| 977e74f669 | |||
| a620833550 | |||
| fcfb16fc5b | |||
| b00e92bdd3 | |||
| 1aa43a5356 | |||
| 51de96c797 | |||
| 0ee82c8b62 | |||
| 111f37a0c9 | |||
| 5fbdda1983 | |||
| b301c543bb | |||
| 3053c09522 | |||
| 52399b35d9 | |||
| 748289b61a | |||
| 2d0e9de155 | |||
| a510b2ca73 |
@@ -112,32 +112,6 @@
|
|||||||
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
|
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
|
||||||
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
|
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
|
||||||
|
|
||||||
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
|
|
||||||
|
|
||||||
### Contexte
|
|
||||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
|
|
||||||
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
|
|
||||||
|
|
||||||
### Décision d'architecture majeure (actée, à valider PO)
|
|
||||||
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
|
|
||||||
|
|
||||||
### Patterns
|
|
||||||
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
|
|
||||||
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
|
|
||||||
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
|
|
||||||
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
|
|
||||||
|
|
||||||
### Gotchas
|
|
||||||
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
|
|
||||||
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
|
|
||||||
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
|
|
||||||
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
|
|
||||||
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
|
|
||||||
|
|
||||||
### MR / Git
|
|
||||||
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62→#56, #63→#62, #57→#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
|
|
||||||
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
|
|
||||||
|
|
||||||
## Meta-learnings
|
## Meta-learnings
|
||||||
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
||||||
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
|
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
|
||||||
@@ -145,25 +119,3 @@
|
|||||||
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
||||||
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
||||||
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
||||||
|
|
||||||
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
|
|
||||||
|
|
||||||
### Contexte
|
|
||||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
|
|
||||||
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
|
|
||||||
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
|
|
||||||
|
|
||||||
### Patterns
|
|
||||||
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
|
|
||||||
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
|
|
||||||
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>` → `core.User`).
|
|
||||||
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
|
|
||||||
|
|
||||||
### Gotchas
|
|
||||||
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
|
|
||||||
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
|
|
||||||
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
|
|
||||||
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
|
|
||||||
|
|
||||||
### Time-tracking / orchestration
|
|
||||||
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
|
|
||||||
|
|||||||
@@ -91,20 +91,6 @@ ENCRYPTION_KEY=change_me_in_env_local
|
|||||||
# POSTGRES_PORT=5435
|
# POSTGRES_PORT=5435
|
||||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# Error tracking — GlitchTip (compatible SDK Sentry)
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
# DSN du projet GlitchTip "lesstime-api" (BACKEND, runtime).
|
|
||||||
# Actif uniquement en prod (bundle prod-only). Vide/absent => Sentry inerte.
|
|
||||||
# A definir dans infra/prod/.env (pas en dev). Ex : http://<cle>@glitchtip.interne:<port>/<id>
|
|
||||||
# SENTRY_DSN=
|
|
||||||
|
|
||||||
# NB : le DSN FRONT (lesstime-front) et l'upload des source maps sont fournis
|
|
||||||
# au BUILD de l'image, pas au runtime. Voir infra/prod/Dockerfile (ARG) et la
|
|
||||||
# CI .gitea/workflows/build-docker.yml (build-args depuis les secrets Gitea) :
|
|
||||||
# NUXT_PUBLIC_SENTRY_DSN, SENTRY_URL, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Frontend (frontend/.env)
|
# Frontend (frontend/.env)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f infra/prod/Dockerfile \
|
-f infra/prod/Dockerfile \
|
||||||
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.SENTRY_FRONT_DSN }}" \
|
|
||||||
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
|
|
||||||
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
|
|
||||||
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_FRONT_PROJECT }}" \
|
|
||||||
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
name: Pull Request — Quality gate
|
|
||||||
|
|
||||||
# Lance les tests back + le build front sur chaque PR ciblant develop.
|
|
||||||
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
|
|
||||||
# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile".
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
|
|
||||||
# Annule les runs obsoletes quand on repush sur la meme PR.
|
|
||||||
concurrency:
|
|
||||||
group: pr-${{ gitea.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend:
|
|
||||||
name: Backend (PHP CS + PHPUnit)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env:
|
|
||||||
# Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le
|
|
||||||
# suffixe `_test` automatiquement en APP_ENV=test (when@test
|
|
||||||
# dbname_suffix) → la base reellement utilisee est `app_test`.
|
|
||||||
POSTGRES_USER: app
|
|
||||||
POSTGRES_PASSWORD: '!ChangeMe!'
|
|
||||||
POSTGRES_DB: app
|
|
||||||
# Pas de `ports:` host mapping : les jobs Gitea Actions tournent en
|
|
||||||
# container sur un reseau Docker dedie, le service est joignable via
|
|
||||||
# son nom (`postgres`), pas via 127.0.0.1.
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U app"
|
|
||||||
--health-interval 5s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 10
|
|
||||||
|
|
||||||
env:
|
|
||||||
APP_ENV: test
|
|
||||||
APP_SECRET: ci-secret-not-used
|
|
||||||
APP_DEBUG: 0
|
|
||||||
DEFAULT_URI: http://localhost/
|
|
||||||
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
|
|
||||||
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
|
|
||||||
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
|
|
||||||
JWT_PASSPHRASE: ci-passphrase
|
|
||||||
# Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que
|
|
||||||
# les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test
|
|
||||||
# alignee sur phpunit.dist.xml.
|
|
||||||
ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP 8.4
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
# zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par
|
|
||||||
# le chiffrement des secrets, ctype/iconv par le require de composer.json.
|
|
||||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv
|
|
||||||
coverage: none
|
|
||||||
tools: composer:v2
|
|
||||||
|
|
||||||
- name: Install PHP dependencies
|
|
||||||
run: composer install --no-interaction --no-progress --prefer-dist
|
|
||||||
|
|
||||||
- name: Generate JWT keypair
|
|
||||||
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
|
|
||||||
|
|
||||||
- name: PHP CS Fixer (dry-run)
|
|
||||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
|
||||||
|
|
||||||
- name: Bootstrap test database
|
|
||||||
# Miroir de la cible `db-reset` du makefile (create + migrate + fixtures),
|
|
||||||
# en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ;
|
|
||||||
# sync-permissions complete le catalogue de permissions comme en install reelle.
|
|
||||||
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:fixtures:load --env=test --no-interaction
|
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
|
||||||
|
|
||||||
- name: Run PHPUnit
|
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend (build)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node 24
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '24'
|
|
||||||
|
|
||||||
# `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/).
|
|
||||||
- name: Install Node dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
|
|
||||||
# (SPA), le prerender n'apporte rien a une quality gate — on valide seulement
|
|
||||||
# que le bundle compile.
|
|
||||||
- name: Build production (nuxt build)
|
|
||||||
run: npm run build
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
||||||
|
}
|
||||||
|
},
|
||||||
"lesstime-local": {
|
"lesstime-local": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": [
|
"args": [
|
||||||
|
|||||||
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
|||||||
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
- Après modif nginx : `docker restart nginx-lesstime`
|
- Après modif nginx : `docker restart nginx-lesstime`
|
||||||
|
|
||||||
## Déploiement (prod Docker)
|
|
||||||
|
|
||||||
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
|
|
||||||
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
|
|
||||||
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
|
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
|
|
||||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Application de gestion de projet avec suivi du temps et portail client.
|
|||||||
- Intégration Gitea (issues, repos)
|
- Intégration Gitea (issues, repos)
|
||||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||||
- Serveur MCP pour assistants IA
|
- Serveur MCP pour assistants IA
|
||||||
- Error tracking centralisé back + front (GlitchTip / SDK Sentry, prod uniquement — voir « Error tracking »)
|
|
||||||
- Multi-langue (i18n)
|
- Multi-langue (i18n)
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
@@ -75,7 +74,6 @@ peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles
|
|||||||
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
||||||
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
||||||
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||||
| `SENTRY_DSN` | Error tracking **backend** → GlitchTip (projet `lesstime-api`) | _(vide)_ | ⚪ optionnel — active le tracking (voir « Error tracking ») |
|
|
||||||
|
|
||||||
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
||||||
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||||
@@ -257,65 +255,6 @@ Le script active la maintenance, pull l'image, redémarre le container, lance le
|
|||||||
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||||
**`doc/deployment-docker.md`**.
|
**`doc/deployment-docker.md`**.
|
||||||
|
|
||||||
## Error tracking (GlitchTip)
|
|
||||||
|
|
||||||
Les erreurs **backend** et **frontend** sont remontées vers **GlitchTip** (instance auto-hébergée
|
|
||||||
interne, compatible SDK Sentry) qui les **groupe par projet** et compte les occurrences. Activé
|
|
||||||
**uniquement en prod** : en dev, sans DSN, le SDK est inerte (zéro impact). Ticket de référence :
|
|
||||||
INFRA #146.
|
|
||||||
|
|
||||||
### Pourquoi back et front se configurent différemment
|
|
||||||
|
|
||||||
| | Backend (Symfony) | Frontend (Nuxt SPA) |
|
|
||||||
|---|---|---|
|
|
||||||
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
|
|
||||||
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
|
|
||||||
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI |
|
|
||||||
|
|
||||||
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
|
|
||||||
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
|
|
||||||
> pourrait lire une variable d'environnement).
|
|
||||||
|
|
||||||
### Variables
|
|
||||||
|
|
||||||
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) :
|
|
||||||
```env
|
|
||||||
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend — secrets Gitea** (repo → Settings → Actions → Secrets), consommés par
|
|
||||||
`.gitea/workflows/build-docker.yml` :
|
|
||||||
|
|
||||||
| Secret Gitea | Rôle |
|
|
||||||
|---|---|
|
|
||||||
| `SENTRY_FRONT_DSN` | DSN du projet `lesstime-front` (public, baké dans le JS) |
|
|
||||||
| `SENTRY_URL` | URL de l'instance GlitchTip |
|
|
||||||
| `SENTRY_ORG` | slug de l'organisation GlitchTip |
|
|
||||||
| `SENTRY_FRONT_PROJECT` | slug du projet front |
|
|
||||||
| `SENTRY_AUTH_TOKEN` | token d'upload des **source maps** (vrai secret) |
|
|
||||||
|
|
||||||
> Sans source maps, seul `SENTRY_FRONT_DSN` est requis (les stacktraces front seront sur du JS
|
|
||||||
> minifié). Le build n'échoue pas si les autres secrets sont absents.
|
|
||||||
|
|
||||||
### Fichiers concernés
|
|
||||||
|
|
||||||
| Fichier | Rôle |
|
|
||||||
|---|---|
|
|
||||||
| `config/packages/sentry.yaml` | conf backend (prod-only, exceptions, 4xx ignorés, release = `app.version`) |
|
|
||||||
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
|
|
||||||
| `frontend/nuxt.config.ts` | module Sentry chargé **uniquement si DSN présent** + upload source maps |
|
|
||||||
| `frontend/sentry.client.config.ts` | init du SDK client (no-op si DSN vide) |
|
|
||||||
| `infra/prod/Dockerfile` | build-args front (`NUXT_PUBLIC_SENTRY_DSN`, `SENTRY_*`) |
|
|
||||||
| `.gitea/workflows/build-docker.yml` | injection des secrets Gitea en build-args |
|
|
||||||
|
|
||||||
### Activation (résumé)
|
|
||||||
|
|
||||||
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
|
|
||||||
(+ un auth token pour les source maps).
|
|
||||||
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur.
|
|
||||||
3. Frontend : ajouter les secrets Gitea ci-dessus.
|
|
||||||
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
Propriétaire — Tous droits réservés.
|
Propriétaire — Tous droits réservés.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"phpoffice/phpspreadsheet": "^5.5",
|
"phpoffice/phpspreadsheet": "^5.5",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"sabre/vobject": "^4.5",
|
"sabre/vobject": "^4.5",
|
||||||
"sentry/sentry-symfony": "^5.10",
|
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/doctrine-messenger": "^8.0",
|
"symfony/doctrine-messenger": "^8.0",
|
||||||
|
|||||||
Generated
+1
-419
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "106755bef51fd069316cd7f3a7e1a0b6",
|
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2508,125 +2508,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-08T16:21:46+00:00"
|
"time": "2026-02-08T16:21:46+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "guzzlehttp/psr7",
|
|
||||||
"version": "2.12.3",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/guzzle/psr7.git",
|
|
||||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
|
||||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^7.2.5 || ^8.0",
|
|
||||||
"psr/http-factory": "^1.0",
|
|
||||||
"psr/http-message": "^1.1 || ^2.0",
|
|
||||||
"ralouphie/getallheaders": "^3.0",
|
|
||||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
|
||||||
"symfony/polyfill-php80": "^1.25"
|
|
||||||
},
|
|
||||||
"provide": {
|
|
||||||
"psr/http-factory-implementation": "1.0",
|
|
||||||
"psr/http-message-implementation": "1.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
|
||||||
"http-interop/http-factory-tests": "1.1.0",
|
|
||||||
"jshttp/mime-db": "1.54.0.1",
|
|
||||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"bamarni-bin": {
|
|
||||||
"bin-links": true,
|
|
||||||
"forward-command": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"GuzzleHttp\\Psr7\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Graham Campbell",
|
|
||||||
"email": "hello@gjcampbell.co.uk",
|
|
||||||
"homepage": "https://github.com/GrahamCampbell"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Michael Dowling",
|
|
||||||
"email": "mtdowling@gmail.com",
|
|
||||||
"homepage": "https://github.com/mtdowling"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "George Mponos",
|
|
||||||
"email": "gmponos@gmail.com",
|
|
||||||
"homepage": "https://github.com/gmponos"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Tobias Nyholm",
|
|
||||||
"email": "tobias.nyholm@gmail.com",
|
|
||||||
"homepage": "https://github.com/Nyholm"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Márk Sági-Kazár",
|
|
||||||
"email": "mark.sagikazar@gmail.com",
|
|
||||||
"homepage": "https://github.com/sagikazarmark"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Tobias Schultze",
|
|
||||||
"email": "webmaster@tubo-world.de",
|
|
||||||
"homepage": "https://github.com/Tobion"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Márk Sági-Kazár",
|
|
||||||
"email": "mark.sagikazar@gmail.com",
|
|
||||||
"homepage": "https://sagikazarmark.hu"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PSR-7 message implementation that also provides common utility methods",
|
|
||||||
"keywords": [
|
|
||||||
"http",
|
|
||||||
"message",
|
|
||||||
"psr-7",
|
|
||||||
"request",
|
|
||||||
"response",
|
|
||||||
"stream",
|
|
||||||
"uri",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/guzzle/psr7/issues",
|
|
||||||
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/GrahamCampbell",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/Nyholm",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-06-23T15:21:08+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "icewind/smb",
|
"name": "icewind/smb",
|
||||||
"version": "3.8.1",
|
"version": "3.8.1",
|
||||||
@@ -3079,66 +2960,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-04T12:34:54+00:00"
|
"time": "2026-05-04T12:34:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "jean85/pretty-package-versions",
|
|
||||||
"version": "2.1.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
|
||||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
|
||||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer-runtime-api": "^2.1.0",
|
|
||||||
"php": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
|
||||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
|
||||||
"phpstan/phpstan": "^2.0",
|
|
||||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
|
||||||
"rector/rector": "^2.0",
|
|
||||||
"vimeo/psalm": "^4.3 || ^5.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Jean85\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Alessandro Lai",
|
|
||||||
"email": "alessandro.lai85@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A library to get pretty versions strings of installed dependencies",
|
|
||||||
"keywords": [
|
|
||||||
"composer",
|
|
||||||
"package",
|
|
||||||
"release",
|
|
||||||
"versions"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
|
||||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
|
||||||
},
|
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "lcobucci/jwt",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -5118,50 +4939,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2021-10-29T13:26:27+00:00"
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ralouphie/getallheaders",
|
|
||||||
"version": "3.0.3",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/ralouphie/getallheaders.git",
|
|
||||||
"reference": "120b605dfeb996808c31b6477290a714d356e822"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
|
|
||||||
"reference": "120b605dfeb996808c31b6477290a714d356e822",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=5.6"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"php-coveralls/php-coveralls": "^2.1",
|
|
||||||
"phpunit/phpunit": "^5 || ^6.5"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/getallheaders.php"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Ralph Khattar",
|
|
||||||
"email": "ralph.khattar@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A polyfill for getallheaders.",
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
|
||||||
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
|
||||||
},
|
|
||||||
"time": "2019-03-08T08:55:37+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "sabre/uri",
|
"name": "sabre/uri",
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
@@ -5395,201 +5172,6 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-06T08:00:55+00:00"
|
"time": "2024-09-06T08:00:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "sentry/sentry",
|
|
||||||
"version": "4.28.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/getsentry/sentry-php.git",
|
|
||||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
|
||||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-curl": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
|
||||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
|
||||||
"php": "^7.2|^8.0",
|
|
||||||
"psr/log": "^1.0|^2.0|^3.0",
|
|
||||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"raven/raven": "*"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"carthage-software/mago": "1.30.0",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.4",
|
|
||||||
"guzzlehttp/promises": "^2.0.3",
|
|
||||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
|
||||||
"nyholm/psr7": "^1.8",
|
|
||||||
"open-telemetry/api": "^1.0",
|
|
||||||
"open-telemetry/exporter-otlp": "^1.0",
|
|
||||||
"open-telemetry/sdk": "^1.0",
|
|
||||||
"phpstan/phpstan": "^1.3",
|
|
||||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
|
||||||
"spiral/roadrunner-http": "^3.6",
|
|
||||||
"spiral/roadrunner-worker": "^3.6"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
|
||||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/functions.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Sentry\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sentry",
|
|
||||||
"email": "accounts@sentry.io"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
|
||||||
"homepage": "http://sentry.io",
|
|
||||||
"keywords": [
|
|
||||||
"crash-reporting",
|
|
||||||
"crash-reports",
|
|
||||||
"error-handler",
|
|
||||||
"error-monitoring",
|
|
||||||
"log",
|
|
||||||
"logging",
|
|
||||||
"profiling",
|
|
||||||
"sentry",
|
|
||||||
"tracing"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
|
||||||
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/pricing/",
|
|
||||||
"type": "custom"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-06-11T12:22:38+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sentry/sentry-symfony",
|
|
||||||
"version": "5.10.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/getsentry/sentry-symfony.git",
|
|
||||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
|
||||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"guzzlehttp/psr7": "^2.1.1",
|
|
||||||
"jean85/pretty-package-versions": "^1.5||^2.0",
|
|
||||||
"php": "^7.2||^8.0",
|
|
||||||
"sentry/sentry": "^4.23.0",
|
|
||||||
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
|
||||||
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/polyfill-php80": "^1.22",
|
|
||||||
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
|
|
||||||
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
|
||||||
"doctrine/doctrine-bundle": "^2.6||^3.0",
|
|
||||||
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
|
||||||
"masterminds/html5": "^2.8",
|
|
||||||
"phpstan/extension-installer": "^1.0",
|
|
||||||
"phpstan/phpstan": "1.12.5",
|
|
||||||
"phpstan/phpstan-phpunit": "1.4.0",
|
|
||||||
"phpstan/phpstan-symfony": "1.4.10",
|
|
||||||
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
|
||||||
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/monolog-bundle": "^3.4||^4.0",
|
|
||||||
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
|
||||||
"vimeo/psalm": "^4.3||^5.16.0"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
|
||||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
|
||||||
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
|
||||||
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
|
||||||
},
|
|
||||||
"type": "symfony-bundle",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/aliases.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Sentry\\SentryBundle\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sentry",
|
|
||||||
"email": "accounts@sentry.io"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
|
||||||
"homepage": "http://getsentry.com",
|
|
||||||
"keywords": [
|
|
||||||
"errors",
|
|
||||||
"logging",
|
|
||||||
"sentry",
|
|
||||||
"symfony"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
|
||||||
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/pricing/",
|
|
||||||
"type": "custom"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-04-01T14:50:32+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.6",
|
"version": "v8.0.6",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
|||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
use Sentry\SentryBundle\SentryBundle;
|
|
||||||
use Symfony\AI\McpBundle\McpBundle;
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
@@ -25,5 +24,4 @@ return [
|
|||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
McpBundle::class => ['all' => true],
|
McpBundle::class => ['all' => true],
|
||||||
MonologBundle::class => ['all' => true],
|
MonologBundle::class => ['all' => true],
|
||||||
SentryBundle::class => ['prod' => true],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,22 +7,8 @@ declare(strict_types=1);
|
|||||||
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
|
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Module\Absence\AbsenceModule;
|
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
use App\Module\Directory\DirectoryModule;
|
|
||||||
use App\Module\Integration\IntegrationModule;
|
|
||||||
use App\Module\Mail\MailModule;
|
|
||||||
use App\Module\ProjectManagement\ProjectManagementModule;
|
|
||||||
use App\Module\Reporting\ReportingModule;
|
|
||||||
use App\Module\TimeTracking\TimeTrackingModule;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
TimeTrackingModule::class,
|
|
||||||
ProjectManagementModule::class,
|
|
||||||
AbsenceModule::class,
|
|
||||||
DirectoryModule::class,
|
|
||||||
MailModule::class,
|
|
||||||
IntegrationModule::class,
|
|
||||||
ReportingModule::class,
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Lesstime API
|
title: Lesstime API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
|
|
||||||
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
|
|
||||||
# discovered via service autoconfiguration, but #[ApiFilter] services are only
|
|
||||||
# registered for classes found in these paths — without them, every filter is
|
|
||||||
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
|
|
||||||
mapping:
|
|
||||||
paths:
|
|
||||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
|
|
||||||
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
|
|
||||||
formats:
|
formats:
|
||||||
jsonld: ['application/ld+json']
|
jsonld: ['application/ld+json']
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
default_connection: default
|
|
||||||
connections:
|
|
||||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
|
||||||
# service) to write outside the ORM transaction so audit rows survive
|
|
||||||
# an application-side rollback and avoid transactional entanglement.
|
|
||||||
default:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
|
||||||
|
# IMPORTANT: You MUST configure your server version,
|
||||||
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
|
#server_version: '16'
|
||||||
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
|
||||||
# from schema comparison so migrations:diff / schema:validate stay
|
|
||||||
# clean. Creation/teardown stay driven by migrations.
|
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
|
||||||
audit:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
orm:
|
orm:
|
||||||
validate_xml_mapping: true
|
validate_xml_mapping: true
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
@@ -22,58 +15,25 @@ doctrine:
|
|||||||
auto_mapping: true
|
auto_mapping: true
|
||||||
resolve_target_entities:
|
resolve_target_entities:
|
||||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||||
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
|
|
||||||
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
|
|
||||||
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
|
|
||||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
|
|
||||||
mappings:
|
mappings:
|
||||||
|
App:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
|
prefix: 'App\Entity'
|
||||||
|
alias: App
|
||||||
Core:
|
Core:
|
||||||
type: attribute
|
type: attribute
|
||||||
is_bundle: false
|
is_bundle: false
|
||||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||||
prefix: 'App\Module\Core\Domain\Entity'
|
prefix: 'App\Module\Core\Domain\Entity'
|
||||||
TimeTracking:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
|
||||||
prefix: 'App\Module\TimeTracking\Domain\Entity'
|
|
||||||
ProjectManagement:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
|
||||||
prefix: 'App\Module\ProjectManagement\Domain\Entity'
|
|
||||||
Absence:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
|
|
||||||
prefix: 'App\Module\Absence\Domain\Entity'
|
|
||||||
Directory:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
|
|
||||||
prefix: 'App\Module\Directory\Domain\Entity'
|
|
||||||
Mail:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
|
|
||||||
prefix: 'App\Module\Mail\Domain\Entity'
|
|
||||||
Integration:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
|
|
||||||
prefix: 'App\Module\Integration\Domain\Entity'
|
|
||||||
controller_resolver:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
dbal:
|
dbal:
|
||||||
# Propagate the _test suffix to BOTH connections: the audit
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
# connection must write to the test DB, not the dev DB.
|
|
||||||
connections:
|
|
||||||
default:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
audit:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
when@prod:
|
when@prod:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ framework:
|
|||||||
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||||
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||||
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||||
'App\Module\Mail\Application\Message\MailSyncRequested': sync
|
'App\Message\MailSyncRequested': sync
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
framework:
|
framework:
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
|
||||||
# Actif uniquement en prod (bundle enregistre seulement pour prod dans bundles.php).
|
|
||||||
# Si SENTRY_DSN est vide/non defini, le SDK est inerte (rien n'est envoye).
|
|
||||||
when@prod:
|
|
||||||
parameters:
|
|
||||||
# Valeur par defaut : DSN vide => Sentry desactive tant qu'il n'est pas fourni.
|
|
||||||
env(SENTRY_DSN): ''
|
|
||||||
|
|
||||||
sentry:
|
|
||||||
dsn: '%env(SENTRY_DSN)%'
|
|
||||||
# Capture les exceptions levees par le kernel (comportement par defaut).
|
|
||||||
register_error_listener: true
|
|
||||||
register_error_handler: true
|
|
||||||
options:
|
|
||||||
environment: '%env(APP_ENV)%'
|
|
||||||
release: '%app.version%'
|
|
||||||
# Pas d'APM/tracing (DuckDB hors perimetre du ticket #146).
|
|
||||||
traces_sample_rate: 0.0
|
|
||||||
# Ne pas remonter les 4xx HTTP comme des erreurs (bruit).
|
|
||||||
ignore_exceptions:
|
|
||||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
|
||||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
|
||||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
|
||||||
@@ -1752,90 +1752,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* }>,
|
* }>,
|
||||||
* }
|
* }
|
||||||
* @psalm-type SentryConfig = array{
|
|
||||||
* dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events.
|
|
||||||
* register_error_listener?: bool|Param, // Default: true
|
|
||||||
* register_error_handler?: bool|Param, // Default: true
|
|
||||||
* logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null
|
|
||||||
* options?: array{
|
|
||||||
* integrations?: mixed, // Default: []
|
|
||||||
* default_integrations?: bool|Param,
|
|
||||||
* prefixes?: list<scalar|Param|null>,
|
|
||||||
* sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events.
|
|
||||||
* enable_tracing?: bool|Param,
|
|
||||||
* traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions.
|
|
||||||
* traces_sampler?: scalar|Param|null,
|
|
||||||
* profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate
|
|
||||||
* enable_logs?: bool|Param,
|
|
||||||
* log_flush_threshold?: mixed, // Default: null
|
|
||||||
* enable_metrics?: bool|Param, // Default: true
|
|
||||||
* attach_stacktrace?: bool|Param,
|
|
||||||
* attach_metric_code_locations?: bool|Param,
|
|
||||||
* context_lines?: int|Param,
|
|
||||||
* environment?: scalar|Param|null, // Default: "%kernel.environment%"
|
|
||||||
* logger?: scalar|Param|null,
|
|
||||||
* spotlight?: bool|Param,
|
|
||||||
* spotlight_url?: scalar|Param|null,
|
|
||||||
* release?: scalar|Param|null, // Default: "%env(default::SENTRY_RELEASE)%"
|
|
||||||
* org_id?: int|Param,
|
|
||||||
* server_name?: scalar|Param|null,
|
|
||||||
* ignore_exceptions?: list<scalar|Param|null>,
|
|
||||||
* ignore_transactions?: list<scalar|Param|null>,
|
|
||||||
* before_send?: scalar|Param|null,
|
|
||||||
* before_send_transaction?: scalar|Param|null,
|
|
||||||
* before_send_check_in?: scalar|Param|null,
|
|
||||||
* before_send_metrics?: scalar|Param|null,
|
|
||||||
* before_send_log?: scalar|Param|null,
|
|
||||||
* before_send_metric?: scalar|Param|null,
|
|
||||||
* trace_propagation_targets?: mixed,
|
|
||||||
* strict_trace_continuation?: bool|Param,
|
|
||||||
* tags?: array<string, scalar|Param|null>,
|
|
||||||
* error_types?: scalar|Param|null,
|
|
||||||
* max_breadcrumbs?: int|Param,
|
|
||||||
* before_breadcrumb?: mixed,
|
|
||||||
* in_app_exclude?: list<scalar|Param|null>,
|
|
||||||
* in_app_include?: list<scalar|Param|null>,
|
|
||||||
* send_default_pii?: bool|Param,
|
|
||||||
* max_value_length?: int|Param,
|
|
||||||
* transport?: scalar|Param|null,
|
|
||||||
* http_client?: scalar|Param|null,
|
|
||||||
* http_proxy?: scalar|Param|null,
|
|
||||||
* http_proxy_authentication?: scalar|Param|null,
|
|
||||||
* http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport.
|
|
||||||
* http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport.
|
|
||||||
* http_ssl_verify_peer?: bool|Param,
|
|
||||||
* http_compression?: bool|Param,
|
|
||||||
* capture_silenced_errors?: bool|Param,
|
|
||||||
* max_request_body_size?: "none"|"never"|"small"|"medium"|"always"|Param,
|
|
||||||
* class_serializers?: array<string, scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* messenger?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* capture_soft_fails?: bool|Param, // Default: true
|
|
||||||
* isolate_breadcrumbs_by_message?: bool|Param, // Default: false
|
|
||||||
* isolate_context_by_message?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* tracing?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* dbal?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* ignore_prepare_spans?: bool|Param, // Default: false
|
|
||||||
* connections?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* twig?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: false
|
|
||||||
* },
|
|
||||||
* cache?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* },
|
|
||||||
* http_client?: bool|array{
|
|
||||||
* enabled?: bool|Param, // Default: true
|
|
||||||
* },
|
|
||||||
* console?: array{
|
|
||||||
* excluded_commands?: list<scalar|Param|null>,
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* }
|
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1876,7 +1792,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* sentry?: SentryConfig,
|
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
+9
-89
@@ -31,122 +31,42 @@ services:
|
|||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|
||||||
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
|
App\EventListener\TaskDocumentListener:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
tags:
|
tags:
|
||||||
- { name: doctrine.orm.entity_listener }
|
- { name: doctrine.orm.entity_listener }
|
||||||
|
|
||||||
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
|
App\State\TaskDocumentProcessor:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
|
App\Controller\TaskDocumentDownloadController:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
|
App\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|
||||||
App\Module\Core\Infrastructure\Controller\UserAvatarController:
|
App\Controller\UserAvatarController:
|
||||||
arguments:
|
arguments:
|
||||||
$avatarUploadDir: '%avatar_upload_dir%'
|
$avatarUploadDir: '%avatar_upload_dir%'
|
||||||
|
|
||||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
|
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%absence_justification_upload_dir%'
|
$uploadDir: '%absence_justification_upload_dir%'
|
||||||
|
|
||||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
|
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%absence_justification_upload_dir%'
|
$uploadDir: '%absence_justification_upload_dir%'
|
||||||
|
|
||||||
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
|
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||||
|
|
||||||
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
|
|
||||||
|
|
||||||
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
|
|
||||||
|
|
||||||
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
|
|
||||||
|
|
||||||
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
|
|
||||||
|
|
||||||
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
|
|
||||||
|
|
||||||
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
||||||
|
|
||||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
|
||||||
|
|
||||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
|
||||||
|
|
||||||
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
|
|
||||||
|
|
||||||
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
|
|
||||||
|
|
||||||
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
|
|
||||||
|
|
||||||
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
|
|
||||||
|
|
||||||
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
|
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
|
||||||
tags:
|
|
||||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
|
||||||
arguments:
|
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\Controller\ReportDocumentDownloadController:
|
|
||||||
arguments:
|
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener:
|
|
||||||
arguments:
|
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
|
||||||
tags:
|
|
||||||
- { name: doctrine.orm.entity_listener }
|
|
||||||
|
|
||||||
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
|
|
||||||
|
|
||||||
App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailFolderRepository'
|
|
||||||
|
|
||||||
App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailMessageRepository'
|
|
||||||
|
|
||||||
App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineTaskMailLinkRepository'
|
|
||||||
|
|
||||||
App\Module\Mail\Domain\Provider\MailProviderInterface: '@App\Module\Mail\Infrastructure\Imap\ImapMailProvider'
|
|
||||||
|
|
||||||
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
||||||
|
|||||||
+9
-26
@@ -4,17 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||||
* Filtrée par SidebarFilter :
|
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
|
||||||
* - `module` : route ajoutée à disabledRoutes si module inactif ;
|
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
|
||||||
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
|
* le RBAC fin par permission arrive en #1.2).
|
||||||
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
|
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||||
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
|
|
||||||
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
|
|
||||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
|
|
||||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||||
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
|
|
||||||
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
|
|
||||||
* pour éviter le doublon.
|
|
||||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
@@ -23,18 +17,9 @@ return [
|
|||||||
'icon' => 'mdi:view-dashboard-outline',
|
'icon' => 'mdi:view-dashboard-outline',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.tools.section',
|
|
||||||
'icon' => 'mdi:tools',
|
|
||||||
'items' => [
|
|
||||||
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
|
||||||
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
|
||||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -42,10 +27,8 @@ return [
|
|||||||
'icon' => 'mdi:cog-outline',
|
'icon' => 'mdi:cog-outline',
|
||||||
'roles' => ['ROLE_ADMIN'],
|
'roles' => ['ROLE_ADMIN'],
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
|
||||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
|
||||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.42'
|
app.version: '0.4.30'
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
|
|||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
echo "==> Seeding RBAC system roles (idempotent)..."
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
|
||||||
|
|
||||||
echo "==> Syncing RBAC permissions catalog..."
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
|
||||||
|
|
||||||
echo "==> Clearing cache..."
|
echo "==> Clearing cache..."
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
@@ -300,31 +294,7 @@ cd /var/www/lesstime
|
|||||||
./deploy.sh v0.3.13 # deploie une version specifique
|
./deploy.sh v0.3.13 # deploie une version specifique
|
||||||
```
|
```
|
||||||
|
|
||||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
|
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||||
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RBAC : roles & permissions (post-deploiement)
|
|
||||||
|
|
||||||
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
|
|
||||||
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
|
|
||||||
les peuplent, integrees au `deploy.sh` :
|
|
||||||
|
|
||||||
| Commande | Effet |
|
|
||||||
|----------|-------|
|
|
||||||
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
|
|
||||||
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
|
|
||||||
|
|
||||||
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
|
|
||||||
|
|
||||||
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/lesstime
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
|
||||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,706 +0,0 @@
|
|||||||
# LST-61 (1.3) · Audit log — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
|
|
||||||
|
|
||||||
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
|
|
||||||
|
|
||||||
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
|
|
||||||
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
|
|
||||||
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
|
|
||||||
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
|
|
||||||
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
|
|
||||||
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
|
|
||||||
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
|
|
||||||
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Créés :**
|
|
||||||
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
|
|
||||||
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
|
|
||||||
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
|
|
||||||
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
|
|
||||||
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
|
||||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
|
||||||
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
|
|
||||||
- `tests/Functional/Module/Core/AuditListenerTest.php`
|
|
||||||
- `tests/Functional/Module/Core/AuditLogApiTest.php`
|
|
||||||
- `frontend/modules/core/services/audit-logs.ts`
|
|
||||||
- `frontend/components/admin/AdminAuditTab.vue`
|
|
||||||
|
|
||||||
**Modifiés :**
|
|
||||||
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
|
|
||||||
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
|
|
||||||
- `src/Module/Core/Domain/Entity/User.php` — `#[Auditable]` + `#[AuditIgnore]` password/apiToken
|
|
||||||
- `src/Module/Core/Domain/Entity/Role.php` — `#[Auditable]`
|
|
||||||
- `src/Module/Core/Domain/Entity/Permission.php` — `#[Auditable]`
|
|
||||||
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
|
|
||||||
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
|
|
||||||
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task A: Marquage + table + connexion DBAL audit
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
|
|
||||||
- Create: `migrations/Version20260619XXXXXX.php`
|
|
||||||
- Modify: `config/packages/doctrine.yaml`
|
|
||||||
|
|
||||||
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
|
|
||||||
|
|
||||||
- [ ] **Step A1: Attributs** — créer les deux fichiers :
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
// src/Shared/Domain/Attribute/Auditable.php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Shared\Domain\Attribute;
|
|
||||||
|
|
||||||
use Attribute;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marker placed on a Doctrine entity to enable audit tracking.
|
|
||||||
*
|
|
||||||
* Located in Shared (not Core) so every module can use it without a
|
|
||||||
* circular dependency on Core. Any migrated business entity that should be
|
|
||||||
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
|
|
||||||
*/
|
|
||||||
#[Attribute(Attribute::TARGET_CLASS)]
|
|
||||||
final class Auditable
|
|
||||||
{
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
// src/Shared/Domain/Attribute/AuditIgnore.php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Shared\Domain\Attribute;
|
|
||||||
|
|
||||||
use Attribute;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marker placed on an entity property to exclude it from audit tracking.
|
|
||||||
*
|
|
||||||
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
|
|
||||||
* carries an exact-match blacklist on the most dangerous names as
|
|
||||||
* defense-in-depth, but the base rule is to annotate explicitly here.
|
|
||||||
*/
|
|
||||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
|
||||||
final class AuditIgnore
|
|
||||||
{
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Audit log (LST-61) : append-only `audit_log` table.
|
|
||||||
*
|
|
||||||
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
|
|
||||||
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
|
|
||||||
* flushes from the Doctrine listener. Columns are lowercase snake_case.
|
|
||||||
* Additive only — no DROP/ALTER on existing tables.
|
|
||||||
*/
|
|
||||||
final class Version20260619XXXXXX extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Audit log: create append-only audit_log table + indexes (additive)';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql(<<<'SQL'
|
|
||||||
CREATE TABLE audit_log (
|
|
||||||
id uuid NOT NULL,
|
|
||||||
entity_type VARCHAR(100) NOT NULL,
|
|
||||||
entity_id VARCHAR(64) NOT NULL,
|
|
||||||
action VARCHAR(10) NOT NULL,
|
|
||||||
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
performed_by VARCHAR(100) NOT NULL,
|
|
||||||
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
ip_address VARCHAR(45) DEFAULT NULL,
|
|
||||||
request_id VARCHAR(36) DEFAULT NULL,
|
|
||||||
PRIMARY KEY(id)
|
|
||||||
)
|
|
||||||
SQL);
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
|
||||||
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
|
|
||||||
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('DROP TABLE audit_log');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
|
|
||||||
|
|
||||||
Remplacer :
|
|
||||||
```yaml
|
|
||||||
dbal:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
|
||||||
#server_version: '16'
|
|
||||||
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
|
||||||
```
|
|
||||||
par :
|
|
||||||
```yaml
|
|
||||||
dbal:
|
|
||||||
default_connection: default
|
|
||||||
connections:
|
|
||||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
|
||||||
# service) to write outside the ORM transaction so audit rows survive
|
|
||||||
# an application-side rollback and avoid transactional entanglement.
|
|
||||||
default:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
|
||||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
|
||||||
# from schema comparison so migrations:diff / schema:validate stay
|
|
||||||
# clean. Creation/teardown stay driven by migrations.
|
|
||||||
schema_filter: '~^(?!audit_log$).+~'
|
|
||||||
audit:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
```
|
|
||||||
|
|
||||||
Et remplacer le bloc `when@test` :
|
|
||||||
```yaml
|
|
||||||
when@test:
|
|
||||||
doctrine:
|
|
||||||
dbal:
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
```
|
|
||||||
par :
|
|
||||||
```yaml
|
|
||||||
when@test:
|
|
||||||
doctrine:
|
|
||||||
dbal:
|
|
||||||
# Propagate the _test suffix to BOTH connections: the audit
|
|
||||||
# connection must write to the test DB, not the dev DB.
|
|
||||||
connections:
|
|
||||||
default:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
audit:
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
|
||||||
```
|
|
||||||
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
|
|
||||||
|
|
||||||
- [ ] **Step A5: Appliquer la migration (dev + test)** :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
|
|
||||||
```
|
|
||||||
Expected: table créée, `audit_log` absente de tout diff généré.
|
|
||||||
|
|
||||||
- [ ] **Step A6: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
|
|
||||||
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task B: AuditLogWriter + RequestIdProvider
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
|
|
||||||
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
|
|
||||||
|
|
||||||
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
|
|
||||||
|
|
||||||
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\Audit;
|
|
||||||
|
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
||||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides an HTTP request identifier (UUID v4) shared by every audit row
|
|
||||||
* produced during a single main request. Null in CLI (fixtures, batch).
|
|
||||||
*/
|
|
||||||
final class RequestIdProvider
|
|
||||||
{
|
|
||||||
private ?string $requestId = null;
|
|
||||||
|
|
||||||
#[AsEventListener(event: 'kernel.request')]
|
|
||||||
public function onKernelRequest(RequestEvent $event): void
|
|
||||||
{
|
|
||||||
if (!$event->isMainRequest()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->requestId = Uuid::v4()->toRfc4122();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestId(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Module\Core\Infrastructure\Audit;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Doctrine\DBAL\Types\Types;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
|
||||||
use Symfony\Component\Uid\Uuid;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Low-level service responsible for writing into the `audit_log` table.
|
|
||||||
*
|
|
||||||
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
|
|
||||||
* outside the ORM transaction: audit rows survive an application-side
|
|
||||||
* rollback and avoid transactional entanglement in batch (fixtures).
|
|
||||||
*
|
|
||||||
* Sensitive keys are stripped in defense-in-depth even when entities already
|
|
||||||
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
|
|
||||||
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
|
|
||||||
* business flow.
|
|
||||||
*/
|
|
||||||
final class AuditLogWriter
|
|
||||||
{
|
|
||||||
/** @var list<string> keys always stripped from the `changes` payload */
|
|
||||||
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire(service: 'doctrine.dbal.audit_connection')]
|
|
||||||
private readonly Connection $connection,
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly RequestStack $requestStack,
|
|
||||||
private readonly RequestIdProvider $requestIdProvider,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param string $entityType Format "module.Entity" (e.g. "core.User")
|
|
||||||
* @param string $entityId Entity id (int or serialized UUID)
|
|
||||||
* @param string $action create|update|delete
|
|
||||||
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
|
|
||||||
*/
|
|
||||||
public function log(
|
|
||||||
string $entityType,
|
|
||||||
string $entityId,
|
|
||||||
string $action,
|
|
||||||
array $changes,
|
|
||||||
): void {
|
|
||||||
$filteredChanges = $this->stripSensitive($changes);
|
|
||||||
|
|
||||||
$this->connection->insert('audit_log', [
|
|
||||||
'id' => Uuid::v7()->toRfc4122(),
|
|
||||||
'entity_type' => $entityType,
|
|
||||||
'entity_id' => $entityId,
|
|
||||||
'action' => $action,
|
|
||||||
'changes' => $filteredChanges,
|
|
||||||
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
|
|
||||||
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
|
|
||||||
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
|
|
||||||
'request_id' => $this->requestIdProvider->getRequestId(),
|
|
||||||
], [
|
|
||||||
'id' => Types::GUID,
|
|
||||||
'changes' => Types::JSON,
|
|
||||||
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively removes sensitive keys from the payload.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $data
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function stripSensitive(array $data): array
|
|
||||||
{
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
|
||||||
unset($data[$key]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (is_array($value)) {
|
|
||||||
$data[$key] = $this->stripSensitive($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
|
|
||||||
```
|
|
||||||
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
|
|
||||||
|
|
||||||
- [ ] **Step B4: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Module/Core/Infrastructure/Audit/
|
|
||||||
git commit -m "feat(core) : add audit log writer and request id provider"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task C: AuditListener + marquage des entités Core
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
|
|
||||||
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
|
|
||||||
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
|
|
||||||
|
|
||||||
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
|
|
||||||
|
|
||||||
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)** — `tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Tests\Functional\Module\Core;
|
|
||||||
|
|
||||||
use App\Module\Core\Domain\Entity\User;
|
|
||||||
use Doctrine\DBAL\Connection;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class AuditListenerTest extends KernelTestCase
|
|
||||||
{
|
|
||||||
private EntityManagerInterface $em;
|
|
||||||
private Connection $auditConnection;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
self::bootKernel();
|
|
||||||
$container = self::getContainer();
|
|
||||||
$this->em = $container->get(EntityManagerInterface::class);
|
|
||||||
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
|
|
||||||
// Clean slate for deterministic assertions.
|
|
||||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreateUserIsAudited(): void
|
|
||||||
{
|
|
||||||
$user = $this->makeUser('audit_create_user');
|
|
||||||
$this->em->persist($user);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
|
||||||
self::assertCount(1, $rows);
|
|
||||||
self::assertSame('create', $rows[0]['action']);
|
|
||||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
|
||||||
self::assertArrayHasKey('username', $changes);
|
|
||||||
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
|
|
||||||
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testUpdateUserIsAuditedWithDiff(): void
|
|
||||||
{
|
|
||||||
$user = $this->makeUser('audit_update_user');
|
|
||||||
$this->em->persist($user);
|
|
||||||
$this->em->flush();
|
|
||||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
|
||||||
|
|
||||||
$user->setFirstName('Changed');
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
|
||||||
self::assertCount(1, $rows);
|
|
||||||
self::assertSame('update', $rows[0]['action']);
|
|
||||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
|
||||||
self::assertArrayHasKey('firstName', $changes);
|
|
||||||
self::assertSame('Changed', $changes['firstName']['new']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteUserIsAudited(): void
|
|
||||||
{
|
|
||||||
$user = $this->makeUser('audit_delete_user');
|
|
||||||
$this->em->persist($user);
|
|
||||||
$this->em->flush();
|
|
||||||
$id = (string) $user->getId();
|
|
||||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
|
||||||
|
|
||||||
$this->em->remove($user);
|
|
||||||
$this->em->flush();
|
|
||||||
|
|
||||||
$rows = $this->fetchLogs('core.User', $id);
|
|
||||||
self::assertCount(1, $rows);
|
|
||||||
self::assertSame('delete', $rows[0]['action']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeUser(string $username): User
|
|
||||||
{
|
|
||||||
$user = new User();
|
|
||||||
$user->setUsername($username);
|
|
||||||
$user->setPassword('hashed-secret');
|
|
||||||
$user->setRoles(['ROLE_USER']);
|
|
||||||
|
|
||||||
return $user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function fetchLogs(string $entityType, string $entityId): array
|
|
||||||
{
|
|
||||||
return $this->auditConnection->fetchAllAssociative(
|
|
||||||
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
|
|
||||||
['t' => $entityType, 'id' => $entityId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function tearDown(): void
|
|
||||||
{
|
|
||||||
parent::tearDown();
|
|
||||||
unset($this->em, $this->auditConnection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
|
|
||||||
|
|
||||||
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
|
||||||
```
|
|
||||||
Expected: FAIL.
|
|
||||||
|
|
||||||
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
|
|
||||||
|
|
||||||
- [ ] **Step C4: Marquer les entités Core.**
|
|
||||||
|
|
||||||
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
|
|
||||||
```php
|
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
|
||||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
|
||||||
```
|
|
||||||
```php
|
|
||||||
#[Auditable]
|
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
|
||||||
#[ORM\Table(name: '`user`')]
|
|
||||||
class User implements ...
|
|
||||||
```
|
|
||||||
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
|
|
||||||
|
|
||||||
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
|
|
||||||
|
|
||||||
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
|
|
||||||
|
|
||||||
- [ ] **Step C5: Run le test → succès** :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
|
||||||
```
|
|
||||||
Expected: PASS (3 tests).
|
|
||||||
|
|
||||||
- [ ] **Step C6: Suite complète + cs-fixer** :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
|
||||||
make php-cs-fixer-allow-risky
|
|
||||||
```
|
|
||||||
Expected: tout vert.
|
|
||||||
|
|
||||||
- [ ] **Step C7: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
|
|
||||||
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task D: API de lecture `/api/audit-logs` + permission
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
|
|
||||||
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
|
|
||||||
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
|
|
||||||
|
|
||||||
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
|
|
||||||
|
|
||||||
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
|
|
||||||
```php
|
|
||||||
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
|
||||||
```
|
|
||||||
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
|
|
||||||
|
|
||||||
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
|
|
||||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
|
||||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
|
||||||
|
|
||||||
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
|
|
||||||
|
|
||||||
- [ ] **Step D3: Écrire le test API (échec attendu)** — `tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
|
|
||||||
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
|
|
||||||
- filtre `?action=update` → ne renvoie que des updates.
|
|
||||||
- filtre `?entity_type=core.User`.
|
|
||||||
- `?action=bogus` → 400.
|
|
||||||
- utilisateur sans permission (alice) : 403.
|
|
||||||
- non authentifié : 401.
|
|
||||||
|
|
||||||
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
|
|
||||||
|
|
||||||
- [ ] **Step D4: Run → échec, puis vérifier la route** :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
|
|
||||||
```
|
|
||||||
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
|
|
||||||
|
|
||||||
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step D6: Suite complète + cs-fixer**
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
|
||||||
make php-cs-fixer-allow-risky
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step D7: Commit**
|
|
||||||
```bash
|
|
||||||
git add src/Module/Core/ tests/
|
|
||||||
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task E: Front — page consultation gated RBAC
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
|
|
||||||
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
|
|
||||||
|
|
||||||
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
|
|
||||||
|
|
||||||
- [ ] **Step E1: Service** — `frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
|
|
||||||
|
|
||||||
- [ ] **Step E2: Composant onglet** — `frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
|
|
||||||
|
|
||||||
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
|
|
||||||
|
|
||||||
- [ ] **Step E4: i18n** — `frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
|
|
||||||
|
|
||||||
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
|
|
||||||
```bash
|
|
||||||
cd frontend && npx nuxt build 2>&1 | tail -5
|
|
||||||
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
|
|
||||||
```
|
|
||||||
Expected: build OK (la page admin reste enregistrée).
|
|
||||||
|
|
||||||
- [ ] **Step E6: Commit**
|
|
||||||
```bash
|
|
||||||
git add frontend/
|
|
||||||
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task F: Validation finale + statut
|
|
||||||
|
|
||||||
- [ ] **Step F1: Suite complète verte + login fumée**
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
|
||||||
```
|
|
||||||
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
|
|
||||||
|
|
||||||
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
|
|
||||||
```bash
|
|
||||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
|
|
||||||
```
|
|
||||||
Expected: 0.
|
|
||||||
|
|
||||||
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
|
|
||||||
|
|
||||||
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
|
|
||||||
|
|
||||||
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review (couverture spec)
|
|
||||||
|
|
||||||
| Critère d'acceptation | Tâche |
|
|
||||||
|---|---|
|
|
||||||
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
|
|
||||||
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
|
|
||||||
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
|
|
||||||
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
|
|
||||||
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
|
|
||||||
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
|
|
||||||
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
|
|
||||||
|
|
||||||
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# LST-58 (2.4) — Module Directory : Prospect + front répertoire (plan)
|
|
||||||
|
|
||||||
> Suite de la migration Directory. Client (back) déjà livré (`c5738d2`).
|
|
||||||
> Reste : **entité Prospect** (nouvelle) + **front répertoire** (Clients + Prospects).
|
|
||||||
> Spec produit non fournie → design défini ici de façon raisonnable, à valider au test.
|
|
||||||
> Additif, sans régression. Branche `integration/modular-monolith-0.1-1.3`.
|
|
||||||
|
|
||||||
## Design Prospect (décidé, à valider)
|
|
||||||
Aligné sur `Client` (même module Directory), enrichi des concepts de prospection commerciale.
|
|
||||||
|
|
||||||
**Entité `App\Module\Directory\Domain\Entity\Prospect`** (table `prospect`) :
|
|
||||||
- `id` int PK
|
|
||||||
- `name` string(255) NOT NULL — contact ou société
|
|
||||||
- `company` string(255) nullable
|
|
||||||
- `email` string(255) nullable
|
|
||||||
- `phone` string(50) nullable
|
|
||||||
- `street` string(255) nullable / `city` string(255) nullable / `postalCode` string(20) nullable (alignés Client)
|
|
||||||
- `status` enum `ProspectStatus` NOT NULL (default `New`)
|
|
||||||
- `source` string(255) nullable — origine (recommandation, salon, site web…)
|
|
||||||
- `notes` text nullable
|
|
||||||
- `convertedClient` ManyToOne `ClientInterface` nullable, JoinColumn ON DELETE SET NULL — rempli à la conversion
|
|
||||||
- Timestampable/Blamable (trait) + `#[Auditable]`
|
|
||||||
- Groupes : `prospect:read` / `prospect:write`
|
|
||||||
|
|
||||||
**Enum `App\Module\Directory\Domain\Enum\ProspectStatus`** : `New` (nouveau), `Contacted` (contacté), `Qualified` (qualifié), `Won` (gagné/converti), `Lost` (perdu). Méthode `label(): string` (FR), comme les autres enums.
|
|
||||||
|
|
||||||
**API Platform** (aligné Client) :
|
|
||||||
- `GetCollection` paginationEnabled:false, `is_granted('ROLE_USER')`
|
|
||||||
- `Get` ROLE_USER ; `Post`/`Patch`/`Delete` ROLE_ADMIN
|
|
||||||
- Opération custom **`Post /prospects/{id}/convert`** (processor `ConvertProspectProcessor`) : crée un `Client` à partir du Prospect (name/company→name, email, phone, adresse), lie `convertedClient`, passe `status=Won`. Sécurité ROLE_ADMIN. Renvoie le Prospect mis à jour. Idempotent si déjà converti (renvoie l'existant).
|
|
||||||
- `#[ApiFilter]` SearchFilter sur `status` (filtre répertoire).
|
|
||||||
|
|
||||||
**Repo** : `ProspectRepositoryInterface` (Domain) + `DoctrineProspectRepository` (Infra) + binding.
|
|
||||||
|
|
||||||
**MCP** (cohérent avec clients, sous `Infrastructure/Mcp/Tool/`) : `list-prospects`, `get-prospect`, `create-prospect`, `update-prospect`, `delete-prospect`, `convert-prospect`. Serializer : ajouter `prospect()` dans `src/Mcp/Tool/Serializer.php`.
|
|
||||||
|
|
||||||
**DirectoryModule.permissions()** : ajouter `directory.prospects.view`, `directory.prospects.manage` (additif).
|
|
||||||
|
|
||||||
**Migration additive** : CREATE TABLE prospect (colonnes + FK converted_client→client ON DELETE SET NULL + created_by/updated_by FK user + index + COMMENT). Down = DROP TABLE.
|
|
||||||
|
|
||||||
**Fixtures** : 2-3 prospects de démo (statuts variés), dont un converti.
|
|
||||||
|
|
||||||
## Front répertoire (`frontend/modules/directory/`)
|
|
||||||
Aujourd'hui : pas de page client dédiée (AdminClientTab + picker ProjectDrawer). On crée un vrai répertoire.
|
|
||||||
- `nuxt.config.ts` vide.
|
|
||||||
- `services/` : `clients.ts` (move depuis racine), `prospects.ts` (nouveau) + `dto/{client,prospect}.ts`.
|
|
||||||
- `pages/directory.vue` : page à 2 onglets (Clients / Prospects), tableaux paginés côté client (paginationEnabled:false back), recherche/filtre statut pour prospects.
|
|
||||||
- `components/` : `ClientDrawer.vue` (move depuis `components/client/`), `ProspectDrawer.vue` (nouveau, create/edit + bouton « Convertir en client »).
|
|
||||||
- Sidebar : ajouter item `sidebar.general.directory` → `/directory`, `'module' => 'directory'`, gate ROLE_ADMIN (gestion référentiel).
|
|
||||||
- Réécrire imports consommateurs de `~/services/clients` / `~/services/dto/client` (AdminClientTab, ProjectDrawer, pages projects) → `~/modules/directory/services/...`. AdminClientTab : soit le retirer de /admin au profit de /directory, soit le laisser pointer le nouveau service. Décision : garder AdminClientTab fonctionnel (repoint service) ET ajouter la page /directory (les deux coexistent ; /directory = vue dédiée).
|
|
||||||
- i18n global : ajouter clés `directory.*`, `prospects.*`, `sidebar.general.directory`.
|
|
||||||
|
|
||||||
## Vagues d'exécution
|
|
||||||
1. **Back Prospect** : enum + entité + repo + API (CRUD + convert) + MCP (6 tools) + Serializer + permissions module + fixtures + migration. Vérif cache:clear/migrate/phpunit/cs-fixer → commit.
|
|
||||||
2. **Front Directory** : layer (move client front + page répertoire + ProspectDrawer + prospects service/dto) + sidebar + imports + i18n. Vérif nuxt build → commit.
|
|
||||||
|
|
||||||
## Critères d'acceptation (ticket #58)
|
|
||||||
- [x] Clients en module (fait, c5738d2)
|
|
||||||
- [ ] Prospects en module + front répertoire fonctionnel
|
|
||||||
- [x] resolve_target_entities → Directory\Client
|
|
||||||
- [ ] make test vert, aucune migration destructive
|
|
||||||
- [ ] toggle module directory (sidebar + route /directory)
|
|
||||||
|
|
||||||
## Suite phase 2 (après 2.4)
|
|
||||||
- 2.5 (#67) Module Mail — WIP `docs/mail-integration.md`, à traiter avec précaution.
|
|
||||||
- 2.6 (#68) Module Integration (Gitea/BookStack/Zimbra/Share).
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# LST-65 (2.2) — Module ProjectManagement : plan de migration
|
|
||||||
|
|
||||||
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
|
|
||||||
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
|
|
||||||
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
|
|
||||||
|
|
||||||
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
|
|
||||||
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
|
|
||||||
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
|
|
||||||
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
|
|
||||||
|
|
||||||
## Périmètre (10 entités + écosystème)
|
|
||||||
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
|
|
||||||
Enums : StatusCategory, RecurrenceType.
|
|
||||||
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
|
|
||||||
|
|
||||||
## Décisions d'architecture (figées)
|
|
||||||
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
|
|
||||||
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
|
|
||||||
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
|
|
||||||
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
|
|
||||||
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
|
|
||||||
- PAS de WorkflowInterface (Workflow est intra-module PM).
|
|
||||||
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
|
|
||||||
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X` → `App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
|
|
||||||
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
|
|
||||||
5. **Services CalDavService + RecurrenceCalculator** → `Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
|
|
||||||
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
|
|
||||||
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
|
|
||||||
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
|
|
||||||
9. **resolve_target_entities** final :
|
|
||||||
```
|
|
||||||
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
|
|
||||||
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
|
|
||||||
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
|
|
||||||
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
|
|
||||||
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
|
|
||||||
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
|
|
||||||
|
|
||||||
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
|
|
||||||
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
|
|
||||||
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
|
|
||||||
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
|
|
||||||
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
|
|
||||||
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
|
|
||||||
|
|
||||||
## Tranche 2 — Move mécanique vers le module
|
|
||||||
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
|
|
||||||
|
|
||||||
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
|
|
||||||
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
|
|
||||||
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
|
|
||||||
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
|
|
||||||
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
|
|
||||||
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
|
|
||||||
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
|
|
||||||
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
|
|
||||||
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
|
|
||||||
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
|
|
||||||
|
|
||||||
## Tranche 3 — Timestampable additif (Task + Project)
|
|
||||||
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
|
|
||||||
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
|
|
||||||
3. Champs hors groupes API existants (le trait porte ses propres groupes).
|
|
||||||
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
|
|
||||||
|
|
||||||
## Tranche 4 — Front layer project-management
|
|
||||||
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
|
|
||||||
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
|
|
||||||
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
|
|
||||||
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
|
|
||||||
|
|
||||||
## Critères d'acceptation (ticket)
|
|
||||||
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
|
|
||||||
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
|
|
||||||
- [ ] `make test` vert, aucune migration destructive.
|
|
||||||
- [ ] Toggle module project-management (sidebar + routes) prouvé.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
|||||||
# Migration sidebar vers MalioSidebar — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Remplacer la sidebar maison de Lesstime par le composant `MalioSidebar` de `@malio/layer-ui`, en 3 groupes (Général / Outils / Administration), avec timer + version dans le footer et le logo Malio de Starseed.
|
|
||||||
|
|
||||||
**Architecture:** Modèle backend-driven conservé — `config/sidebar.php` filtré par `SidebarProvider` (permissions/rôles/modules côté serveur), exposé via `/api/sidebar`, consommé par `useSidebar()`. Le layout `default.vue` mappe ces sections vers le format `MalioSidebar` et fusionne les items contextuels rendus côté client (Kanban/Groupes/Archives, Documents, Mail+badge, Mes absences).
|
|
||||||
|
|
||||||
**Tech Stack:** Nuxt 4 (SPA), Vue 3 `<script setup>` TS, Pinia, `@malio/layer-ui` ^1.7.16, i18n (@nuxtjs/i18n), Symfony 8 / API Platform 4 (backend config PHP).
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- **Ne jamais modifier `@malio/layer-ui`** (lib externe). Source de référence en lecture seule : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
|
||||||
- `MalioSidebar` : props `sections` (requis), `modelValue` (v-model collapse bool), `sidebarClass`, `toggleClass`. Item = `{ label: string; to: string; exact?: boolean }` (pas d'icône ni de badge par item). Section = `{ label?: string; icon?: string; items: SidebarItem[] }`. Slots : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
|
||||||
- **TypeScript strict** ; indentation **4 espaces** (frontend).
|
|
||||||
- Backend : `declare(strict_types=1)` en tête des fichiers PHP.
|
|
||||||
- Commits format projet : `type(scope) : message` (espaces autour du `:`), types autorisés minuscules (`feat`, `fix`, `refactor`, `chore`, …). **Ne committer que sur demande de l'utilisateur** (règle CLAUDE.md). Travailler sur une branche dédiée (pas directement sur `develop`).
|
|
||||||
- **Pas de runner de test frontend** dans ce projet → vérification par `npm run build` (Nuxt, échoue sur erreur TS/template) + **QA manuelle navigateur** (`make dev-nuxt`, port 3002). Ne PAS introduire de framework de test (hors scope).
|
|
||||||
- Décisions validées : 3 groupes ; badge mail = **suffixe `(N)`** sur le label.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- `config/sidebar.php` — **Modify** : re-catégorisation en 3 sections.
|
|
||||||
- `frontend/i18n/locales/fr.json` — **Modify** : clés de sections/items.
|
|
||||||
- `frontend/i18n/locales/*.json` (autres langues présentes) — **Modify si existantes** : mêmes clés.
|
|
||||||
- `frontend/public/LOGO_MALIO.png` — **Create** (copie Starseed).
|
|
||||||
- `frontend/public/LOGO_MALIO_COLLAPSED.png` — **Create** (copie Starseed).
|
|
||||||
- `frontend/app/layouts/default.vue` — **Modify** : réécriture du template sidebar + logique `mergedSections`.
|
|
||||||
- `frontend/components/ui/SidebarLink.vue` — **Possible delete** (si plus aucun usage après migration).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 0 : Branche de travail
|
|
||||||
|
|
||||||
**Files:** aucun (git).
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Créer la branche depuis `develop`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/m-tristan/workspace/Lesstime
|
|
||||||
git checkout develop && git pull --ff-only
|
|
||||||
git checkout -b feat/malio-sidebar
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : sur la branche `feat/malio-sidebar`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1 : Backend — re-catégorisation `config/sidebar.php` + i18n
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `config/sidebar.php`
|
|
||||||
- Modify: `frontend/i18n/locales/fr.json`
|
|
||||||
- Modify: autres `frontend/i18n/locales/*.json` si présentes (mêmes clés)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces : `/api/sidebar` renvoie des sections dont les `label` sont les clés `sidebar.general.section`, `sidebar.tools.section`, `sidebar.admin.section`. Items inchangés en `to` ; gates (`module`/`roles`/`permission`) inchangés, juste réorganisés.
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Réécrire `config/sidebar.php` en 3 sections**
|
|
||||||
|
|
||||||
Remplacer le `return [...]` (lignes 20-44) par :
|
|
||||||
|
|
||||||
```php
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.general.section',
|
|
||||||
'icon' => 'mdi:view-dashboard-outline',
|
|
||||||
'items' => [
|
|
||||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
|
||||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
|
||||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
|
||||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.tools.section',
|
|
||||||
'icon' => 'mdi:tools',
|
|
||||||
'items' => [
|
|
||||||
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
|
||||||
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
|
||||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'sidebar.admin.section',
|
|
||||||
'icon' => 'mdi:cog-outline',
|
|
||||||
'roles' => ['ROLE_ADMIN'],
|
|
||||||
'items' => [
|
|
||||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
|
||||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
|
||||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
|
||||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
> Mettre aussi à jour le commentaire d'en-tête si nécessaire (le bloc décrivant Mail/contextuels reste valable).
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Mettre à jour les clés i18n FR**
|
|
||||||
|
|
||||||
Dans `frontend/i18n/locales/fr.json`, bloc `sidebar` :
|
|
||||||
- `sidebar.general.section` : remplacer la valeur par `"Général"`.
|
|
||||||
- Ajouter `sidebar.tools.section` : `"Outils"`.
|
|
||||||
- Conserver `sidebar.general.dashboard|myTasks|projects|timeTracking|mail` et `sidebar.admin.*`.
|
|
||||||
- Ajouter les clés pour items client (utilisées en Task 3) :
|
|
||||||
- `sidebar.general.myAbsences` : `"Mes absences"`
|
|
||||||
- `sidebar.project.kanban` : `"Kanban"`
|
|
||||||
- `sidebar.project.groups` : `"Groupes"`
|
|
||||||
- `sidebar.project.archives` : `"Archives"`
|
|
||||||
|
|
||||||
Résultat attendu du bloc (extrait) :
|
|
||||||
|
|
||||||
```json
|
|
||||||
"sidebar": {
|
|
||||||
"general": {
|
|
||||||
"section": "Général",
|
|
||||||
"dashboard": "Tableau de bord",
|
|
||||||
"myTasks": "Mes tâches",
|
|
||||||
"projects": "Projets",
|
|
||||||
"timeTracking": "Suivi de temps",
|
|
||||||
"mail": "Messagerie",
|
|
||||||
"myAbsences": "Mes absences"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"section": "Outils"
|
|
||||||
},
|
|
||||||
"project": {
|
|
||||||
"kanban": "Kanban",
|
|
||||||
"groups": "Groupes",
|
|
||||||
"archives": "Archives"
|
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"section": "Administration",
|
|
||||||
"teamAbsences": "Absences équipe",
|
|
||||||
"directory": "Répertoire",
|
|
||||||
"administration": "Administration",
|
|
||||||
"reporting": "Rapports"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Répliquer les clés dans les autres locales si présentes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls /home/m-tristan/workspace/Lesstime/frontend/i18n/locales/
|
|
||||||
```
|
|
||||||
|
|
||||||
Pour chaque fichier autre que `fr.json`, ajouter `tools.section`, `general.myAbsences`, `project.kanban|groups|archives` et ajuster `general.section`. S'il n'existe que `fr.json`, ne rien faire de plus.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Vérifier `/api/sidebar` (admin)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -i php-lesstime-fpm php -r 'var_dump(require "/var/www/config/sidebar.php");' | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : le fichier PHP se parse sans erreur (3 entrées de premier niveau). (Le chemin exact dans le container peut différer — sinon, vérifier via `make cache-clear` qui échouerait sur une erreur de syntaxe PHP.)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make cache-clear
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : succès, pas d'erreur de parse.
|
|
||||||
|
|
||||||
- [ ] **Step 5 : Commit (sur demande utilisateur)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add config/sidebar.php frontend/i18n/locales/
|
|
||||||
git commit -m "refactor(sidebar) : re-catégorisation en 3 groupes (Général / Outils / Administration)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2 : Frontend — assets logo
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `frontend/public/LOGO_MALIO.png`
|
|
||||||
- Create: `frontend/public/LOGO_MALIO_COLLAPSED.png`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces : assets statiques servis à `/LOGO_MALIO.png` et `/LOGO_MALIO_COLLAPSED.png`.
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Copier les logos depuis Starseed**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO.png \
|
|
||||||
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO.png
|
|
||||||
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO_COLLAPSED.png \
|
|
||||||
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO_COLLAPSED.png
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -la /home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO*.png
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : deux fichiers présents (~5.8K et ~2.2K).
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Commit (sur demande utilisateur)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/public/LOGO_MALIO.png frontend/public/LOGO_MALIO_COLLAPSED.png
|
|
||||||
git commit -m "chore(sidebar) : ajout des logos Malio (déplié / replié)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3 : Frontend — migration du layout vers `MalioSidebar`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `frontend/app/layouts/default.vue`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes : `useSidebar().sections` (clés i18n des Task 1), `useUiStore().sidebarCollapsed`, `SidebarTimer` (`:collapsed`), `useAppVersion().version`, `useMailStore().globalUnreadCount`, `useShareStatus()`, `auth.user.isEmployee`, `auth.user.roles`, `useI18n().t`.
|
|
||||||
- Produces : layout rendant `<MalioSidebar>`.
|
|
||||||
|
|
||||||
> Ce task est une réécriture cohérente d'un seul fichier : la sidebar doit rester fonctionnelle (toutes features préservées) à la fin du task. On ne committe pas d'état intermédiaire cassé.
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Remplacer le bloc `<aside>…</aside>` (lignes 13-104) par `<MalioSidebar>`**
|
|
||||||
|
|
||||||
Nouveau template de la zone sidebar (remplace l'overlay mobile lignes 5-11 **et** l'`<aside>`) :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<MalioSidebar
|
|
||||||
v-model="ui.sidebarCollapsed"
|
|
||||||
:sections="mergedSections"
|
|
||||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
|
||||||
>
|
|
||||||
<template #logo>
|
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
|
||||||
</template>
|
|
||||||
<template #logo-collapsed>
|
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<SidebarTimer :collapsed="false" />
|
|
||||||
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer-collapsed>
|
|
||||||
<SidebarTimer :collapsed="true" />
|
|
||||||
</template>
|
|
||||||
</MalioSidebar>
|
|
||||||
```
|
|
||||||
|
|
||||||
Le bloc `<div class="h-full flex-1 …">` (AppTopNav + `<main>` + `<slot/>`) et le `<TimeEntryDrawer>` restent **inchangés**.
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Remplacer la logique `translatedSections` par `mergedSections` dans le `<script setup>`**
|
|
||||||
|
|
||||||
Supprimer le computed `translatedSections` (lignes 144-156) et le remplacer par :
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type MalioItem = { label: string; to: string; exact?: boolean }
|
|
||||||
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
|
||||||
|
|
||||||
// Ordre d'affichage canonique des sections.
|
|
||||||
const SECTION_ORDER = [
|
|
||||||
'sidebar.general.section',
|
|
||||||
'sidebar.tools.section',
|
|
||||||
'sidebar.admin.section',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
|
||||||
// ex. module mail off mais partage actif → section Outils à recréer).
|
|
||||||
const SECTION_ICON: Record<string, string> = {
|
|
||||||
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
|
||||||
'sidebar.tools.section': 'mdi:tools',
|
|
||||||
'sidebar.admin.section': 'mdi:cog-outline',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
|
||||||
function clientItemsFor(key: string): MalioItem[] {
|
|
||||||
if (key === 'sidebar.general.section') {
|
|
||||||
const items: MalioItem[] = []
|
|
||||||
if (currentProjectId.value) {
|
|
||||||
const id = currentProjectId.value
|
|
||||||
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true })
|
|
||||||
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups` })
|
|
||||||
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives` })
|
|
||||||
}
|
|
||||||
if (isEmployee.value) {
|
|
||||||
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
if (key === 'sidebar.tools.section') {
|
|
||||||
const items: MalioItem[] = []
|
|
||||||
if (isMailVisible.value) {
|
|
||||||
const n = mailStore.globalUnreadCount
|
|
||||||
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
|
||||||
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
|
||||||
}
|
|
||||||
if (isDocumentsVisible.value) {
|
|
||||||
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedSections = computed<MalioSection[]>(() => {
|
|
||||||
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
|
||||||
const backend = new Map<string, MalioSection>()
|
|
||||||
for (const section of sections.value) {
|
|
||||||
backend.set(section.label, {
|
|
||||||
label: t(section.label),
|
|
||||||
icon: section.icon,
|
|
||||||
items: section.items
|
|
||||||
.filter((item) => item.to !== '/mail')
|
|
||||||
.map((item) => ({ label: t(item.label), to: item.to })),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fusion dans l'ordre canonique.
|
|
||||||
const result: MalioSection[] = []
|
|
||||||
for (const key of SECTION_ORDER) {
|
|
||||||
const base = backend.get(key)
|
|
||||||
const extra = clientItemsFor(key)
|
|
||||||
if (base) {
|
|
||||||
base.items.push(...extra)
|
|
||||||
if (base.items.length > 0) {
|
|
||||||
result.push(base)
|
|
||||||
}
|
|
||||||
} else if (extra.length > 0) {
|
|
||||||
result.push({ label: t(key), icon: SECTION_ICON[key], items: extra })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
|
||||||
for (const [key, section] of backend) {
|
|
||||||
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
|
||||||
result.push(section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
> `isDocumentsVisible` existe déjà (ligne 166). `isMailVisible`, `isEmployee`, `currentProjectId`, `sections`, `mailStore`, `t`, `version`, `ui` sont déjà déclarés — ne pas les redéclarer.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Nettoyer le `<script>` et les imports devenus inutiles**
|
|
||||||
|
|
||||||
- Supprimer `sidebarIsCollapsed` (computed lignes 169-172) **si** plus utilisé après suppression de l'`<aside>` (l'était pour le rendu manuel). Vérifier qu'aucune autre référence ne subsiste :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "sidebarIsCollapsed" frontend/app/layouts/default.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
S'il ne reste aucune occurrence hors déclaration, supprimer le computed.
|
|
||||||
|
|
||||||
- Conserver `watch(() => route.path, () => { ui.closeMobileSidebar() })` (fermeture mobile sur navigation).
|
|
||||||
- Vérifier que `SidebarLink` n'est plus référencé dans ce fichier (le composant Malio le remplace) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "SidebarLink" frontend/app/layouts/default.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : aucune occurrence.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Build de vérification**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : build Nuxt réussi, **aucune erreur TypeScript** ni de template. (Si `mergedSections`/types invalides, le build échoue ici.)
|
|
||||||
|
|
||||||
- [ ] **Step 5 : QA manuelle (dev server)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev-nuxt # port 3002
|
|
||||||
```
|
|
||||||
|
|
||||||
Vérifier en **admin** (`admin`/`admin`) :
|
|
||||||
- 3 groupes : Général, Outils, Administration.
|
|
||||||
- Général : Tableau de bord, Mes tâches, Projets, Suivi de temps.
|
|
||||||
- En ouvrant un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent dans Général ; Kanban actif uniquement sur la page kanban (exact).
|
|
||||||
- Outils : Messagerie (+ `(N)` si non-lus), Documents (si partage activé).
|
|
||||||
- Administration : Absences équipe, Répertoire, Rapports, Administration.
|
|
||||||
- Footer : timer cliquable (start/stop) + `v <version>` ; en replié, le timer reste (icône) et la version disparaît.
|
|
||||||
- Logo Malio déplié + replié (collapsed via toggle du composant).
|
|
||||||
- Route active surlignée ; pas de doublon `/mail`.
|
|
||||||
|
|
||||||
Vérifier en **utilisateur non-admin** (`alice`/`alice`) :
|
|
||||||
- **Pas** de groupe Administration.
|
|
||||||
- Items gated par permission absents si l'utilisateur n'a pas la permission.
|
|
||||||
- Mes absences visible uniquement si `isEmployee`.
|
|
||||||
|
|
||||||
- [ ] **Step 6 : Vérifier le comportement mobile (largeur < lg)**
|
|
||||||
|
|
||||||
Réduire la fenêtre / activer le responsive devtools.
|
|
||||||
- Vérifier l'ouverture/fermeture de la sidebar sur mobile.
|
|
||||||
- Vérifier le bouton hamburger éventuel de `AppTopNav` :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "openMobileSidebar\|sidebarOpen\|closeMobileSidebar" frontend/app/components/ frontend/components/ frontend/app/layouts/default.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
- Si `MalioSidebar` gère le responsive et que l'overlay supprimé n'est plus nécessaire : OK.
|
|
||||||
- Si l'ouverture mobile ne fonctionne plus (ex. AppTopNav appelait `openMobileSidebar` pour l'ancien overlay) : adapter **sans modifier la lib** — a minima conserver le repli/déploiement via `ui.sidebarCollapsed`, ou conserver un déclencheur. Documenter le choix retenu dans le commit.
|
|
||||||
|
|
||||||
- [ ] **Step 7 : Commit (sur demande utilisateur)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add frontend/app/layouts/default.vue
|
|
||||||
git commit -m "feat(sidebar) : migration du layout vers MalioSidebar (footer timer + version, logo Malio)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4 : Nettoyage des éléments obsolètes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Possible delete: `frontend/components/ui/SidebarLink.vue`
|
|
||||||
- Possible delete: anciens logos `frontend/public/malio.png`, `frontend/public/LOGO_CARRE.png`
|
|
||||||
|
|
||||||
**Interfaces:** aucun (suppression sûre uniquement si zéro référence).
|
|
||||||
|
|
||||||
- [ ] **Step 1 : Vérifier les usages restants de `SidebarLink`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "SidebarLink" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" | grep -v node_modules
|
|
||||||
```
|
|
||||||
|
|
||||||
- Si **aucune** occurrence : supprimer le fichier.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm frontend/components/ui/SidebarLink.vue
|
|
||||||
```
|
|
||||||
|
|
||||||
- Si encore référencé ailleurs : **ne pas supprimer**, laisser tel quel.
|
|
||||||
|
|
||||||
- [ ] **Step 2 : Vérifier les usages des anciens logos**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "malio.png\|LOGO_CARRE.png" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" --include="*.css" | grep -v node_modules
|
|
||||||
```
|
|
||||||
|
|
||||||
- Si **aucune** occurrence : supprimer les deux PNG.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm frontend/public/malio.png frontend/public/LOGO_CARRE.png
|
|
||||||
```
|
|
||||||
|
|
||||||
- Sinon : conserver.
|
|
||||||
|
|
||||||
- [ ] **Step 3 : Build final**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected : build réussi.
|
|
||||||
|
|
||||||
- [ ] **Step 4 : Commit (sur demande utilisateur)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(sidebar) : suppression des composants/assets obsolètes de l'ancienne sidebar"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review (auteur du plan)
|
|
||||||
|
|
||||||
**Spec coverage :**
|
|
||||||
- Remplacement par MalioSidebar → Task 3 ✓
|
|
||||||
- Permissions serveur préservées → Task 1 (gates inchangés) + Task 3 (mail filtré/ré-injecté, garde-fou sections) ✓
|
|
||||||
- 3 groupes Général/Outils/Administration → Task 1 + Task 3 (ordre canonique) ✓
|
|
||||||
- Footer timer + version → Task 3 Step 1 ✓
|
|
||||||
- Logo Malio Starseed → Task 2 + Task 3 ✓
|
|
||||||
- Items contextuels (Kanban/Groupes/Archives, Documents, Mes absences) → Task 3 `clientItemsFor` ✓
|
|
||||||
- Badge mail = suffixe `(N)` → Task 3 `clientItemsFor` ✓
|
|
||||||
- Mobile → Task 3 Step 6 ✓
|
|
||||||
- Nettoyage → Task 4 ✓
|
|
||||||
|
|
||||||
**Placeholder scan :** pas de TBD ; les branches conditionnelles de suppression (Task 4) et d'adaptation mobile (Task 3 Step 6) sont des décisions binaires basées sur un `grep`, pas des placeholders.
|
|
||||||
|
|
||||||
**Type consistency :** `MalioItem`/`MalioSection` définis une fois (Task 3) et utilisés de façon cohérente ; `clientItemsFor`/`mergedSections`/`SECTION_ORDER`/`SECTION_ICON` cohérents. Items produits conformes au type attendu par `MalioSidebar` (`{label, to, exact?}`).
|
|
||||||
|
|
||||||
**Réserve connue :** absence de runner de test FE → vérification par build + QA manuelle (assumé, conforme à l'état du repo).
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Répertoire — Contacts, Adresses & Rapports commerciaux
|
|
||||||
|
|
||||||
**Date :** 2026-06-22
|
|
||||||
**Module :** `Directory` (Lesstime)
|
|
||||||
**Statut :** Conception validée — prêt pour plan d'implémentation
|
|
||||||
|
|
||||||
## Contexte & objectif
|
|
||||||
|
|
||||||
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
|
|
||||||
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
|
|
||||||
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
|
|
||||||
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
|
|
||||||
|
|
||||||
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
|
|
||||||
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
|
|
||||||
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
|
|
||||||
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
|
|
||||||
type d'échange + auteur) auquel on peut **joindre des documents**.
|
|
||||||
|
|
||||||
Décisions cadrées avec l'utilisateur :
|
|
||||||
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
|
|
||||||
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
|
|
||||||
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
|
|
||||||
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
|
|
||||||
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
|
|
||||||
|
|
||||||
## Approche retenue
|
|
||||||
|
|
||||||
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
|
|
||||||
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
|
|
||||||
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
|
|
||||||
|
|
||||||
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
|
|
||||||
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
|
|
||||||
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
|
|
||||||
FK** (pas de copie), ce qui préserve l'historique.
|
|
||||||
|
|
||||||
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
|
|
||||||
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
|
|
||||||
|
|
||||||
## Modèle de données (backend — `src/Module/Directory`)
|
|
||||||
|
|
||||||
Toutes les nouvelles entités vivent dans le module `Directory`
|
|
||||||
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
|
|
||||||
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
|
|
||||||
sont `#[Auditable]` comme `Client`/`Prospect`.
|
|
||||||
|
|
||||||
### `Contact` (répétable)
|
|
||||||
| Champ | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | int PK | |
|
|
||||||
| firstName | string? | |
|
|
||||||
| lastName | string? | |
|
|
||||||
| jobTitle | string? | fonction |
|
|
||||||
| email | string? | lowercase |
|
|
||||||
| phonePrimary | string? | |
|
|
||||||
| phoneSecondary | string? | |
|
|
||||||
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
|
|
||||||
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
|
|
||||||
|
|
||||||
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
|
|
||||||
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
|
|
||||||
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
|
|
||||||
`isContactNamed` de Starseed).
|
|
||||||
|
|
||||||
### `Address` (répétable)
|
|
||||||
| Champ | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | int PK | |
|
|
||||||
| label | string? | libellé libre (« Siège », « Facturation »…) |
|
|
||||||
| street | string? | |
|
|
||||||
| streetComplement | string? | |
|
|
||||||
| postalCode | string? | |
|
|
||||||
| city | string? | |
|
|
||||||
| country | string | défaut `FR` |
|
|
||||||
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
|
|
||||||
|
|
||||||
### `CommercialReport` (compte-rendu, répétable)
|
|
||||||
| Champ | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| id | int PK | |
|
|
||||||
| subject | string | objet du compte-rendu |
|
|
||||||
| body | text | le compte-rendu lui-même |
|
|
||||||
| occurredAt | date | date de l'échange |
|
|
||||||
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
|
|
||||||
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
|
|
||||||
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
|
|
||||||
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
|
|
||||||
|
|
||||||
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
|
|
||||||
|
|
||||||
### Migration de l'adresse *inline*
|
|
||||||
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
|
|
||||||
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
|
|
||||||
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
|
|
||||||
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
|
|
||||||
restent sur `Client`/`Prospect` (identité principale).
|
|
||||||
|
|
||||||
### Documents des comptes-rendus
|
|
||||||
|
|
||||||
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
|
|
||||||
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
|
|
||||||
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
|
|
||||||
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
|
|
||||||
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
|
|
||||||
> recouple `ProjectManagement` ↔ `Directory`.
|
|
||||||
|
|
||||||
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
|
|
||||||
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
|
|
||||||
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
|
|
||||||
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
|
|
||||||
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
|
|
||||||
croisée `ProjectManagement` → `Directory`) au prix d'une duplication maîtrisée du processor
|
|
||||||
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
|
|
||||||
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
|
|
||||||
dépendent que du DTO document + de l'URL de download).
|
|
||||||
|
|
||||||
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
|
|
||||||
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
|
|
||||||
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
|
|
||||||
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
|
|
||||||
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
|
|
||||||
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
|
|
||||||
`GetCollection` filtrable par `commercialReport`.
|
|
||||||
|
|
||||||
## API Platform
|
|
||||||
|
|
||||||
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
|
|
||||||
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
|
||||||
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
|
|
||||||
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
|
|
||||||
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
|
|
||||||
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
|
|
||||||
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
|
|
||||||
`author` (id + username) et `documents`.
|
|
||||||
|
|
||||||
Permissions RBAC ajoutées au `Module::permissions()` :
|
|
||||||
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
|
|
||||||
`directory.clients.*` / `directory.prospects.*` existants.)
|
|
||||||
|
|
||||||
## Conversion prospect → client
|
|
||||||
|
|
||||||
`ConvertProspectProcessor`
|
|
||||||
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
|
|
||||||
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
|
|
||||||
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
|
|
||||||
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
|
|
||||||
automatiquement (rattachés au `CommercialReport`, pas au prospect).
|
|
||||||
|
|
||||||
## Frontend (Nuxt — `frontend/modules/directory`)
|
|
||||||
|
|
||||||
### Liste & navigation
|
|
||||||
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
|
|
||||||
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
|
|
||||||
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
|
|
||||||
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
|
|
||||||
|
|
||||||
### Fiches détail
|
|
||||||
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
|
|
||||||
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
|
|
||||||
- Bloc principal (identité : name/email/phone…), éditable en place.
|
|
||||||
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
|
|
||||||
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
|
|
||||||
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
|
|
||||||
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
|
|
||||||
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
|
|
||||||
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
|
|
||||||
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
|
|
||||||
|
|
||||||
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
|
|
||||||
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
|
|
||||||
|
|
||||||
### Services & DTO
|
|
||||||
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
|
|
||||||
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
|
|
||||||
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
|
|
||||||
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
|
|
||||||
|
|
||||||
## i18n
|
|
||||||
|
|
||||||
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
|
|
||||||
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
|
|
||||||
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
|
|
||||||
|
|
||||||
## Tests (PHPUnit)
|
|
||||||
|
|
||||||
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
|
|
||||||
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
|
|
||||||
client (`prospect = null`), idempotence.
|
|
||||||
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
|
|
||||||
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
|
|
||||||
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
|
|
||||||
ayant une adresse non vide).
|
|
||||||
|
|
||||||
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
|
|
||||||
> (relations, statuts, présence), pas des **counts absolus**.
|
|
||||||
|
|
||||||
## Hors périmètre (YAGNI)
|
|
||||||
|
|
||||||
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
|
|
||||||
- Pas de dashboard/statistiques commerciales chiffrées.
|
|
||||||
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
|
|
||||||
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# Migration de la sidebar vers `MalioSidebar` (@malio/layer-ui)
|
|
||||||
|
|
||||||
**Date** : 2026-06-25
|
|
||||||
**Statut** : Design validé
|
|
||||||
**Scope** : Frontend (layout) + backend (config sidebar) + assets
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
|
|
||||||
La sidebar actuelle de Lesstime est un `<aside>` fait main dans
|
|
||||||
`frontend/app/layouts/default.vue`, qui itère sur les sections renvoyées par
|
|
||||||
`/api/sidebar` et rend chaque item via le composant maison `SidebarLink`. Le
|
|
||||||
timer et la version sont empilés en bas du `<aside>`, le toggle collapse et
|
|
||||||
l'overlay mobile sont gérés manuellement.
|
|
||||||
|
|
||||||
La librairie `@malio/layer-ui` (mise à jour) fournit désormais un composant
|
|
||||||
`MalioSidebar`. Le projet **Starseed** a déjà effectué cette migration sur une
|
|
||||||
architecture identique (`config/sidebar.php` → `SidebarProvider` → composable
|
|
||||||
`useSidebar` → layout). Cette spec applique la même migration à Lesstime, avec
|
|
||||||
trois spécificités Lesstime : footer (timer + version), re-catégorisation des
|
|
||||||
onglets, et plusieurs items contextuels rendus côté client.
|
|
||||||
|
|
||||||
On **ne modifie pas** la lib `@malio/layer-ui` (règle CLAUDE.md).
|
|
||||||
|
|
||||||
## Objectifs
|
|
||||||
|
|
||||||
1. Remplacer le `<aside>` maison par `<MalioSidebar>`.
|
|
||||||
2. Préserver le filtrage des permissions/rôles/modules **côté serveur**.
|
|
||||||
3. Re-catégoriser la navigation en 3 groupes : **Général / Outils / Administration**.
|
|
||||||
4. Mettre le timer et la version dans le **footer** du composant.
|
|
||||||
5. Reprendre le **logo Malio** de Starseed.
|
|
||||||
|
|
||||||
## Décisions validées
|
|
||||||
|
|
||||||
- **Catégorisation** : 3 groupes (option B).
|
|
||||||
- **Badge mail** : le compteur de non-lus devient un **suffixe sur le label**
|
|
||||||
(`Messagerie (3)`), faute de slot badge/icône par item dans `MalioSidebar`.
|
|
||||||
|
|
||||||
## Contraintes du composant `MalioSidebar`
|
|
||||||
|
|
||||||
Source : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
|
||||||
|
|
||||||
- **Props** : `sections` (requis), `modelValue` (v-model collapse, bool),
|
|
||||||
`id`, `sidebarClass`, `toggleClass`.
|
|
||||||
- **Types** :
|
|
||||||
- `SidebarItem = { label: string; to: string; exact?: boolean }`
|
|
||||||
- `SidebarSection = { label?: string; icon?: string; items: SidebarItem[] }`
|
|
||||||
- **Slots** : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
|
||||||
- **Events** : `update:modelValue(boolean)`.
|
|
||||||
- **Item** : pas d'icône par item ni de badge — uniquement l'icône de section.
|
|
||||||
Route active = match exact ou par préfixe (`exact: true` pour exact strict).
|
|
||||||
- Largeurs fixes : 232px (déplié) / 72px (replié). Toggle interne.
|
|
||||||
|
|
||||||
### Conséquences (compromis assumés)
|
|
||||||
|
|
||||||
- Perte de l'**icône par item** (design malioUI = texte + icône de section).
|
|
||||||
Starseed fonctionne ainsi.
|
|
||||||
- Le **badge mail** ne peut pas être une pastille → suffixe `(N)` dans le label.
|
|
||||||
|
|
||||||
## Architecture cible
|
|
||||||
|
|
||||||
Modèle **backend-driven** conservé (sécurité serveur intacte). Le frontend
|
|
||||||
mappe les sections renvoyées par `/api/sidebar` vers le format `MalioSidebar`
|
|
||||||
et **fusionne** les items contextuels (qui dépendent d'un état runtime non
|
|
||||||
connu du backend).
|
|
||||||
|
|
||||||
### 1. Backend — `config/sidebar.php`
|
|
||||||
|
|
||||||
Re-catégorisation en 3 sections (gates inchangés, juste réorganisés) :
|
|
||||||
|
|
||||||
```
|
|
||||||
GÉNÉRAL (sidebar.general.section, icon mdi:view-dashboard-outline)
|
|
||||||
Tableau de bord / —
|
|
||||||
Mes tâches /my-tasks module project-management, perm tasks.view
|
|
||||||
Projets /projects module project-management, perm projects.view
|
|
||||||
Suivi de temps /time-tracking module time-tracking, perm entries.view
|
|
||||||
|
|
||||||
OUTILS (sidebar.tools.section, icon mdi:tools)
|
|
||||||
Messagerie /mail module mail
|
|
||||||
(filtré du rendu backend côté front, ré-injecté avec badge)
|
|
||||||
|
|
||||||
ADMINISTRATION (sidebar.admin.section, icon mdi:cog-outline, roles [ROLE_ADMIN])
|
|
||||||
Absences équipe /team-absences module absence
|
|
||||||
Répertoire /directory module directory
|
|
||||||
Rapports /reporting module reporting, perm reporting.view
|
|
||||||
Administration /admin perm core.users.view
|
|
||||||
```
|
|
||||||
|
|
||||||
> `/mail` reste déclaré pour le gating module (`disabledRoutes`), mais est
|
|
||||||
> filtré des sections rendues et ré-injecté côté client avec son badge, comme
|
|
||||||
> aujourd'hui.
|
|
||||||
|
|
||||||
### 2. i18n — `frontend/i18n/locales/fr.json`
|
|
||||||
|
|
||||||
- Renommer `sidebar.general.section` : « Gestion de projet » → « Général ».
|
|
||||||
- Ajouter `sidebar.tools.section` : « Outils ».
|
|
||||||
- Conserver les clés d'items existantes. Items client : réutiliser les clés
|
|
||||||
existantes quand elles existent (`sharedFiles.sidebar.title` pour Documents,
|
|
||||||
`mail.sidebar.title`/`sidebar.general.mail` pour Messagerie) ; ajouter une
|
|
||||||
clé pour « Mes absences » (aujourd'hui en dur) et pour les contextuels
|
|
||||||
(Kanban/Groupes/Archives, aujourd'hui en dur) si on souhaite les traduire,
|
|
||||||
sinon conserver les libellés en dur actuels.
|
|
||||||
|
|
||||||
### 3. Frontend — `frontend/app/layouts/default.vue`
|
|
||||||
|
|
||||||
Réécriture du template autour de `<MalioSidebar>` :
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<MalioSidebar v-model="ui.sidebarCollapsed" :sections="mergedSections"
|
|
||||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'">
|
|
||||||
<template #logo> <img src="/LOGO_MALIO.png" alt="Malio"/></template>
|
|
||||||
<template #logo-collapsed> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/></template>
|
|
||||||
<template #footer>
|
|
||||||
<SidebarTimer :collapsed="false" />
|
|
||||||
<p class="font-bold">v {{ version }}</p>
|
|
||||||
</template>
|
|
||||||
<template #footer-collapsed>
|
|
||||||
<SidebarTimer :collapsed="true" />
|
|
||||||
</template>
|
|
||||||
</MalioSidebar>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Computed `mergedSections`** : construit les sections finales dans l'ordre
|
|
||||||
canonique `[général, outils, administration]`.
|
|
||||||
|
|
||||||
Logique de fusion :
|
|
||||||
1. Partir des sections backend (déjà filtrées), mappées en
|
|
||||||
`{ label: t(label), icon, items: items.filter(to !== '/mail').map({label: t, to}) }`.
|
|
||||||
2. Définir une table `clientItems` indexée par clé de section :
|
|
||||||
- `sidebar.general.section` → (si `currentProjectId`) Kanban (`exact`),
|
|
||||||
Groupes, Archives ; puis (si `isEmployee`) Mes absences.
|
|
||||||
- `sidebar.tools.section` → (si `isMailVisible`) Messagerie avec label
|
|
||||||
`Messagerie` + suffixe `(N)` quand `mailStore.globalUnreadCount > 0`
|
|
||||||
(`99+` au-delà) ; puis (si `shareEnabled`) Documents.
|
|
||||||
3. Pour chaque section backend, **append** ses items client.
|
|
||||||
4. Si une clé de `clientItems` produit des items mais que la section
|
|
||||||
correspondante n'est **pas** présente dans la réponse backend (ex. module
|
|
||||||
mail off mais partage on → pas de section « Outils » côté backend), **créer**
|
|
||||||
la section côté front (label + icône depuis une table locale).
|
|
||||||
5. **Supprimer** les sections finales sans items.
|
|
||||||
6. Trier selon l'ordre canonique des clés.
|
|
||||||
|
|
||||||
Le reste du `<script>` (timer title watchers, `refData`/`TimeEntryDrawer`,
|
|
||||||
polling mail, `ensureShareStatus`, `currentProjectId`, `isEmployee`,
|
|
||||||
`isMailVisible`, `shareEnabled`) est **conservé tel quel**.
|
|
||||||
|
|
||||||
### 4. Mobile
|
|
||||||
|
|
||||||
Starseed a **supprimé l'overlay mobile custom** et ne garde que
|
|
||||||
`watch(route) → ui.closeMobileSidebar()`. On s'aligne : suppression du markup
|
|
||||||
overlay (`ui.sidebarOpen`, `.sidebar-overlay`) si `MalioSidebar` gère le
|
|
||||||
responsive. **À vérifier à l'implémentation** : comportement mobile réel du
|
|
||||||
composant ; si l'ouverture mobile n'est pas couverte, adapter a minima sans
|
|
||||||
modifier la lib.
|
|
||||||
|
|
||||||
### 5. Assets — logo
|
|
||||||
|
|
||||||
Copier depuis Starseed vers `frontend/public/` :
|
|
||||||
- `LOGO_MALIO.png` (128×44)
|
|
||||||
- `LOGO_MALIO_COLLAPSED.png` (34×40)
|
|
||||||
|
|
||||||
Les anciens `/malio.png` et `/LOGO_CARRE.png` ne sont plus référencés par le
|
|
||||||
layout (les laisser ou les retirer si plus aucun usage — à vérifier).
|
|
||||||
|
|
||||||
## Composants / éléments réutilisés
|
|
||||||
|
|
||||||
- `SidebarTimer` (`components/ui/SidebarTimer.vue`) : inchangé, déjà piloté par
|
|
||||||
`:collapsed`.
|
|
||||||
- `useAppVersion()` : inchangé.
|
|
||||||
- `useSidebar()` : inchangé.
|
|
||||||
- `usePermissions()` : inchangé (le filtrage permission reste backend ; les
|
|
||||||
flags client `isEmployee`/`isMailVisible`/`shareEnabled` restent locaux).
|
|
||||||
|
|
||||||
## Éléments supprimés
|
|
||||||
|
|
||||||
- Le `<aside>` manuel et son markup (logo, nav, toggle, overlay) dans
|
|
||||||
`default.vue`.
|
|
||||||
- L'usage de `SidebarLink` dans le layout (le composant peut rester s'il est
|
|
||||||
utilisé ailleurs — à vérifier ; sinon suppression possible).
|
|
||||||
|
|
||||||
## Critères d'acceptation
|
|
||||||
|
|
||||||
1. La sidebar est rendue par `<MalioSidebar>`.
|
|
||||||
2. 3 groupes : Général, Outils, Administration (Administration visible
|
|
||||||
uniquement pour `ROLE_ADMIN` / permissions, comme avant).
|
|
||||||
3. Toutes les permissions/rôles/modules sont respectés à l'identique (aucune
|
|
||||||
régression de visibilité pour user/admin).
|
|
||||||
4. Items contextuels présents : Kanban/Groupes/Archives (dans un projet),
|
|
||||||
Documents (partage activé), Mes absences (employé).
|
|
||||||
5. Messagerie affiche `(N)` quand il y a des non-lus.
|
|
||||||
6. Footer : timer fonctionnel + version (version masquée en replié).
|
|
||||||
7. Logo Malio de Starseed affiché (déplié + replié).
|
|
||||||
8. Collapse/expand et route active fonctionnent.
|
|
||||||
9. Pas de doublon `/mail`. Pas de section vide affichée.
|
|
||||||
10. Build Nuxt OK, pas d'erreur TS.
|
|
||||||
|
|
||||||
## Hors scope
|
|
||||||
|
|
||||||
- Refonte du `SiteSelector` (n'existe pas dans Lesstime).
|
|
||||||
- Modification de la lib `@malio/layer-ui`.
|
|
||||||
- Changement du modèle de permissions backend.
|
|
||||||
+135
-125
@@ -1,31 +1,112 @@
|
|||||||
<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
|
<!-- Mobile sidebar overlay -->
|
||||||
v-model="ui.sidebarCollapsed"
|
<Transition name="sidebar-overlay">
|
||||||
:sections="mergedSections"
|
<div
|
||||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
v-if="ui.sidebarOpen"
|
||||||
|
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||||
|
:class="[
|
||||||
|
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
||||||
|
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<template #logo>
|
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
<img
|
||||||
</template>
|
v-if="!sidebarIsCollapsed"
|
||||||
<template #logo-collapsed>
|
src="/malio.png"
|
||||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
alt="Logo"
|
||||||
</template>
|
class="w-auto"
|
||||||
<template #footer>
|
/>
|
||||||
<div class="flex flex-col gap-2">
|
<img
|
||||||
<SidebarTimer :collapsed="false" />
|
v-else
|
||||||
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
src="/LOGO_CARRE.png"
|
||||||
|
alt="Logo"
|
||||||
|
class="w-[46px] h-[55px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="20" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||||
|
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
|
||||||
|
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
|
||||||
|
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||||
|
{{ section.label }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||||
|
<SidebarLink
|
||||||
|
v-for="item in section.items"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
:icon="item.icon"
|
||||||
|
:label="item.label"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
|
||||||
|
<template v-if="sIndex === 0">
|
||||||
|
<!-- Contextuel projet -->
|
||||||
|
<template v-if="currentProjectId">
|
||||||
|
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
|
||||||
|
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||||
|
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||||
</template>
|
</template>
|
||||||
<template #footer-collapsed>
|
<!-- Feature-flag : Documents -->
|
||||||
<SidebarTimer :collapsed="true" />
|
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||||
|
<!-- Feature-flag : Mail + badge -->
|
||||||
|
<div v-if="isMailVisible" class="relative">
|
||||||
|
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||||
|
<span
|
||||||
|
v-if="mailStore.globalUnreadCount > 0"
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||||
|
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||||
|
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||||
|
>
|
||||||
|
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
|
||||||
|
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||||
</template>
|
</template>
|
||||||
</MalioSidebar>
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center p-4">
|
||||||
|
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
||||||
|
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
||||||
|
@click="ui.toggleSidebar()"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<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">
|
||||||
<AppTopNav :user="auth.user" />
|
<AppTopNav :user="auth.user" />
|
||||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-6 lg:px-12 xl:px-11">
|
<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">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,8 +125,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import { useAppVersion } from '~/composables/useAppVersion'
|
import { useAppVersion } from '~/composables/useAppVersion'
|
||||||
import type { HydraCollection } from '~/utils/api'
|
import type { HydraCollection } from '~/utils/api'
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
import { extractHydraMembers } from '~/utils/api'
|
||||||
@@ -58,6 +139,18 @@ const route = useRoute()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { sections } = useSidebar()
|
const { sections } = useSidebar()
|
||||||
|
|
||||||
|
const translatedSections = computed(() =>
|
||||||
|
sections.value.map((section) => ({
|
||||||
|
label: t(section.label),
|
||||||
|
icon: section.icon,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
label: t(item.label),
|
||||||
|
to: item.to,
|
||||||
|
icon: item.icon,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
||||||
|
|
||||||
const isMailVisible = computed(() => {
|
const isMailVisible = computed(() => {
|
||||||
@@ -68,116 +161,22 @@ const isMailVisible = computed(() => {
|
|||||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||||
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
||||||
|
|
||||||
|
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||||
|
const sidebarIsCollapsed = computed(() => {
|
||||||
|
if (ui.sidebarOpen) return false
|
||||||
|
return ui.sidebarCollapsed
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close mobile sidebar on route change
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
ui.closeMobileSidebar()
|
||||||
|
})
|
||||||
|
|
||||||
const currentProjectId = computed(() => {
|
const currentProjectId = computed(() => {
|
||||||
const match = route.path.match(/^\/projects\/(\d+)/)
|
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||||
return match ? match[1] : null
|
return match ? match[1] : null
|
||||||
})
|
})
|
||||||
|
|
||||||
type MalioItem = { label: string; to: string; exact?: boolean }
|
|
||||||
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
|
||||||
|
|
||||||
// Ordre d'affichage canonique des sections.
|
|
||||||
const SECTION_ORDER = [
|
|
||||||
'sidebar.general.section',
|
|
||||||
'sidebar.tools.section',
|
|
||||||
'sidebar.admin.section',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
|
||||||
// ex. module mail off mais partage actif → section Outils à recréer).
|
|
||||||
const SECTION_ICON: Record<string, string> = {
|
|
||||||
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
|
||||||
'sidebar.tools.section': 'mdi:tools',
|
|
||||||
'sidebar.admin.section': 'mdi:cog-outline',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
|
|
||||||
// (sinon ajouté en fin de section).
|
|
||||||
type ClientItem = MalioItem & { after?: string }
|
|
||||||
|
|
||||||
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
|
||||||
function clientItemsFor(key: string): ClientItem[] {
|
|
||||||
if (key === 'sidebar.general.section') {
|
|
||||||
const items: ClientItem[] = []
|
|
||||||
if (currentProjectId.value) {
|
|
||||||
const id = currentProjectId.value
|
|
||||||
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
|
|
||||||
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
|
|
||||||
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
|
|
||||||
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
|
|
||||||
}
|
|
||||||
if (isEmployee.value) {
|
|
||||||
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
if (key === 'sidebar.tools.section') {
|
|
||||||
const items: ClientItem[] = []
|
|
||||||
if (isMailVisible.value) {
|
|
||||||
const n = mailStore.globalUnreadCount
|
|
||||||
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
|
||||||
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
|
||||||
}
|
|
||||||
if (isDocumentsVisible.value) {
|
|
||||||
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
|
|
||||||
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
|
|
||||||
const result = [...base]
|
|
||||||
for (const { after, ...item } of extra) {
|
|
||||||
const idx = after ? result.findIndex((i) => i.to === after) : -1
|
|
||||||
if (idx !== -1) {
|
|
||||||
result.splice(idx + 1, 0, item)
|
|
||||||
} else {
|
|
||||||
result.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedSections = computed<MalioSection[]>(() => {
|
|
||||||
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
|
||||||
const backend = new Map<string, MalioSection>()
|
|
||||||
for (const section of sections.value) {
|
|
||||||
backend.set(section.label, {
|
|
||||||
label: t(section.label),
|
|
||||||
icon: section.icon,
|
|
||||||
items: section.items
|
|
||||||
.filter((item) => item.to !== '/mail')
|
|
||||||
.map((item) => ({ label: t(item.label), to: item.to })),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fusion dans l'ordre canonique.
|
|
||||||
const result: MalioSection[] = []
|
|
||||||
for (const key of SECTION_ORDER) {
|
|
||||||
const base = backend.get(key)
|
|
||||||
const extra = clientItemsFor(key)
|
|
||||||
if (base) {
|
|
||||||
base.items = mergeClientItems(base.items, extra)
|
|
||||||
if (base.items.length > 0) {
|
|
||||||
result.push(base)
|
|
||||||
}
|
|
||||||
} else if (extra.length > 0) {
|
|
||||||
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], extra) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
|
||||||
for (const [key, section] of backend) {
|
|
||||||
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
|
||||||
result.push(section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
const baseTitle = ref('Lesstime')
|
const baseTitle = ref('Lesstime')
|
||||||
@@ -265,3 +264,14 @@ function onCompleteSaved() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-overlay-enter-active,
|
||||||
|
.sidebar-overlay-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.sidebar-overlay-enter-from,
|
||||||
|
.sidebar-overlay-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+2
-2
@@ -19,8 +19,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+2
-1
@@ -73,7 +73,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||||
|
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
balances: AbsenceBalance[]
|
balances: AbsenceBalance[]
|
||||||
+3
-2
@@ -52,8 +52,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
absences: AbsenceRequest[]
|
absences: AbsenceRequest[]
|
||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HalfDay } from '~/modules/absence/services/dto/absence'
|
import type { HalfDay } from '~/services/dto/absence'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
/** ISO date string "YYYY-MM-DD" or null. */
|
/** ISO date string "YYYY-MM-DD" or null. */
|
||||||
+3
-2
@@ -135,8 +135,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+3
-2
@@ -26,8 +26,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+3
-2
@@ -105,8 +105,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -51,8 +51,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
|
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
|
||||||
const service = useAbsenceService()
|
const service = useAbsenceService()
|
||||||
const rows = ref<AbsencePolicy[]>([])
|
const rows = ref<AbsencePolicy[]>([])
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-4">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="entityTypeFilter"
|
|
||||||
:options="entityTypeOptions"
|
|
||||||
:label="$t('admin.audit.filterEntityType')"
|
|
||||||
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
|
|
||||||
group-class="w-64"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="actionFilter"
|
|
||||||
:options="actionOptions"
|
|
||||||
:label="$t('admin.audit.filterAction')"
|
|
||||||
:empty-option-label="$t('admin.audit.filterActionAll')"
|
|
||||||
group-class="w-64"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:items="rows"
|
|
||||||
:loading="isLoading"
|
|
||||||
:empty-message="$t('admin.audit.empty')"
|
|
||||||
>
|
|
||||||
<template #cell-performedAt="{ item }">
|
|
||||||
{{ formatDate(item.performedAt) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-entityType="{ item }">
|
|
||||||
{{ entityTypeLabel(item.entityType) }}
|
|
||||||
</template>
|
|
||||||
<template #cell-action="{ item }">
|
|
||||||
{{ actionLabel(item.action) }}
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
|
||||||
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('admin.audit.previous')"
|
|
||||||
:disabled="page <= 1 || isLoading"
|
|
||||||
@click="goToPage(page - 1)"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="secondary"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('admin.audit.next')"
|
|
||||||
:disabled="!hasNextPage || isLoading"
|
|
||||||
@click="goToPage(page + 1)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
|
|
||||||
import { useAuditLogService } from '~/modules/core/services/audit-logs'
|
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
|
||||||
|
|
||||||
const columns = computed<DataTableColumn[]>(() => [
|
|
||||||
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
|
|
||||||
{ key: 'performedBy', label: t('admin.audit.performedBy') },
|
|
||||||
{ key: 'entityType', label: t('admin.audit.entityType') },
|
|
||||||
{ key: 'action', label: t('admin.audit.action') },
|
|
||||||
{ key: 'entityId', label: t('admin.audit.entityId') },
|
|
||||||
])
|
|
||||||
|
|
||||||
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
|
|
||||||
{ value: 'create', label: t('audit.action.create') },
|
|
||||||
{ value: 'update', label: t('audit.action.update') },
|
|
||||||
{ value: 'delete', label: t('audit.action.delete') },
|
|
||||||
])
|
|
||||||
|
|
||||||
const auditLogService = useAuditLogService()
|
|
||||||
|
|
||||||
const rows = ref<AuditLogItem[]>([])
|
|
||||||
const entityTypes = ref<string[]>([])
|
|
||||||
const totalItems = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const entityTypeFilter = ref<string | null>(null)
|
|
||||||
const actionFilter = ref<AuditLogAction | null>(null)
|
|
||||||
|
|
||||||
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
|
|
||||||
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
|
|
||||||
)
|
|
||||||
|
|
||||||
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
|
|
||||||
// "next" button accurate even on the last (partial) page.
|
|
||||||
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
|
|
||||||
|
|
||||||
function entityTypeLabel(value: string): string {
|
|
||||||
const key = `audit.entity.${value}`
|
|
||||||
return te(key) ? t(key) : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(action: AuditLogAction): string {
|
|
||||||
return t(`audit.action.${action}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
|
||||||
return new Date(value).toLocaleString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadItems() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const result = await auditLogService.list({
|
|
||||||
page: page.value,
|
|
||||||
entityType: entityTypeFilter.value ?? undefined,
|
|
||||||
action: actionFilter.value ?? undefined,
|
|
||||||
})
|
|
||||||
rows.value = result.items
|
|
||||||
totalItems.value = result.totalItems
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEntityTypes() {
|
|
||||||
entityTypes.value = await auditLogService.entityTypes()
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(target: number) {
|
|
||||||
if (target < 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
page.value = target
|
|
||||||
loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([entityTypeFilter, actionFilter], () => {
|
|
||||||
page.value = 1
|
|
||||||
loadItems()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadItems()
|
|
||||||
loadEntityTypes()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
import { useBookStackService } from '~/services/bookstack'
|
||||||
|
|
||||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Client } from '~/modules/directory/services/dto/client'
|
import type { Client } from '~/services/dto/client'
|
||||||
import { useClientService } from '~/modules/directory/services/clients'
|
import { useClientService } from '~/services/clients'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
|
|
||||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMailService } from '~/modules/mail/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
|
|
||||||
<MalioButton
|
|
||||||
icon-name="mdi:plus"
|
|
||||||
icon-position="left"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('admin.roles.addRole')"
|
|
||||||
@click="openCreate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:items="items"
|
|
||||||
:loading="isLoading"
|
|
||||||
:empty-message="$t('admin.roles.empty')"
|
|
||||||
@row-click="openEdit"
|
|
||||||
>
|
|
||||||
<template #cell-isSystem="{ item }">
|
|
||||||
<span
|
|
||||||
v-if="item.isSystem"
|
|
||||||
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
|
|
||||||
>
|
|
||||||
{{ $t('admin.roles.system') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #cell-permissions="{ item }">
|
|
||||||
<span class="text-neutral-600">{{ item.permissions.length }}</span>
|
|
||||||
</template>
|
|
||||||
<template #actions="{ item }">
|
|
||||||
<MalioButtonIcon
|
|
||||||
v-if="!item.isSystem"
|
|
||||||
icon="mdi:delete-outline"
|
|
||||||
:aria-label="$t('common.delete')"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="20"
|
|
||||||
button-class="text-neutral-400 hover:text-red-500"
|
|
||||||
@click.stop="handleDelete(item.id)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<RoleDrawer
|
|
||||||
v-model="drawerOpen"
|
|
||||||
:item="selectedItem"
|
|
||||||
:permissions="permissions"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Role } from '~/modules/core/services/roles'
|
|
||||||
import { useRoleService } from '~/modules/core/services/roles'
|
|
||||||
import type { Permission } from '~/modules/core/services/permissions'
|
|
||||||
import { usePermissionService } from '~/modules/core/services/permissions'
|
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const columns = computed<DataTableColumn[]>(() => [
|
|
||||||
{ key: 'label', label: t('admin.roles.label'), primary: true },
|
|
||||||
{ key: 'code', label: t('admin.roles.code') },
|
|
||||||
{ key: 'permissions', label: t('admin.roles.permissions') },
|
|
||||||
{ key: 'isSystem', label: '' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const roleService = useRoleService()
|
|
||||||
const permissionService = usePermissionService()
|
|
||||||
|
|
||||||
const items = ref<Role[]>([])
|
|
||||||
const permissions = ref<Permission[]>([])
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const drawerOpen = ref(false)
|
|
||||||
const selectedItem = ref<Role | null>(null)
|
|
||||||
|
|
||||||
async function loadItems() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
items.value = await roleService.list()
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPermissions() {
|
|
||||||
permissions.value = await permissionService.list()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
selectedItem.value = null
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(item: Role) {
|
|
||||||
selectedItem.value = item
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
|
||||||
await roleService.remove(id)
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaved() {
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadItems()
|
|
||||||
loadPermissions()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
|
import { useShareSettingsService } from '~/services/share-settings'
|
||||||
|
|
||||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useZimbraService } from '~/modules/integration/services/zimbra'
|
import { useZimbraService } from '~/services/zimbra'
|
||||||
|
|
||||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||||
|
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MalioDrawer v-model="isOpen">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-xl font-bold">
|
|
||||||
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
|
|
||||||
</h2>
|
|
||||||
</template>
|
|
||||||
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.code"
|
|
||||||
:label="$t('admin.roles.code')"
|
|
||||||
input-class="w-full"
|
|
||||||
:disabled="isEditing"
|
|
||||||
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
|
|
||||||
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
|
|
||||||
@blur="touched.code = true"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.label"
|
|
||||||
:label="$t('admin.roles.label')"
|
|
||||||
input-class="w-full"
|
|
||||||
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
|
|
||||||
@blur="touched.label = true"
|
|
||||||
/>
|
|
||||||
<MalioInputTextArea
|
|
||||||
v-model="form.description"
|
|
||||||
:label="$t('admin.roles.description')"
|
|
||||||
input-class="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<label class="text-sm font-semibold text-neutral-700">
|
|
||||||
{{ $t('admin.roles.permissions') }}
|
|
||||||
</label>
|
|
||||||
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
|
|
||||||
{{ $t('admin.roles.noPermissions') }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="group in groupedPermissions"
|
|
||||||
:key="group.module"
|
|
||||||
class="mt-3 rounded-lg border border-neutral-200 p-3"
|
|
||||||
>
|
|
||||||
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
|
||||||
{{ group.module }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label
|
|
||||||
v-for="perm in group.permissions"
|
|
||||||
:key="perm.id"
|
|
||||||
class="flex items-start gap-2 text-sm text-neutral-700"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="form.permissions"
|
|
||||||
type="checkbox"
|
|
||||||
:value="perm['@id']"
|
|
||||||
class="mt-0.5 rounded border-neutral-300"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{{ perm.label }}
|
|
||||||
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<MalioButton
|
|
||||||
:label="$t('common.save')"
|
|
||||||
button-class="w-auto px-6"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</MalioDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Role, RoleWrite } from '~/modules/core/services/roles'
|
|
||||||
import { useRoleService } from '~/modules/core/services/roles'
|
|
||||||
import type { Permission } from '~/modules/core/services/permissions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
item: Role | null
|
|
||||||
permissions: Permission[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.item)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
code: '',
|
|
||||||
label: '',
|
|
||||||
description: '',
|
|
||||||
permissions: [] as string[],
|
|
||||||
})
|
|
||||||
|
|
||||||
const touched = reactive({
|
|
||||||
code: false,
|
|
||||||
label: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
|
|
||||||
|
|
||||||
const groupedPermissions = computed(() => {
|
|
||||||
const byModule = new Map<string, Permission[]>()
|
|
||||||
for (const perm of props.permissions) {
|
|
||||||
const list = byModule.get(perm.module) ?? []
|
|
||||||
list.push(perm)
|
|
||||||
byModule.set(perm.module, list)
|
|
||||||
}
|
|
||||||
return [...byModule.entries()]
|
|
||||||
.map(([module, permissions]) => ({ module, permissions }))
|
|
||||||
.sort((a, b) => a.module.localeCompare(b.module))
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
if (props.item) {
|
|
||||||
form.code = props.item.code
|
|
||||||
form.label = props.item.label
|
|
||||||
form.description = props.item.description ?? ''
|
|
||||||
form.permissions = props.item.permissions
|
|
||||||
.map((p) => p['@id'])
|
|
||||||
.filter((iri): iri is string => !!iri)
|
|
||||||
} else {
|
|
||||||
form.code = ''
|
|
||||||
form.label = ''
|
|
||||||
form.description = ''
|
|
||||||
form.permissions = []
|
|
||||||
}
|
|
||||||
touched.code = false
|
|
||||||
touched.label = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { create, update } = useRoleService()
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
touched.code = true
|
|
||||||
touched.label = true
|
|
||||||
if (!form.label.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isEditing.value && !codeValid.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
if (isEditing.value && props.item) {
|
|
||||||
const payload: Partial<RoleWrite> = {
|
|
||||||
label: form.label.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
permissions: form.permissions,
|
|
||||||
}
|
|
||||||
await update(props.item.id, payload)
|
|
||||||
} else {
|
|
||||||
const payload: RoleWrite = {
|
|
||||||
code: form.code.trim(),
|
|
||||||
label: form.label.trim(),
|
|
||||||
description: form.description.trim() || null,
|
|
||||||
permissions: form.permissions,
|
|
||||||
}
|
|
||||||
await create(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -96,11 +96,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
|
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||||
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
|
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||||
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
|
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|||||||
+55
-4
@@ -6,11 +6,36 @@
|
|||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
label="Nom société"
|
label="Nom"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||||
@blur="touched.name = true"
|
@blur="touched.name = true"
|
||||||
/>
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.email"
|
||||||
|
label="Email"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.phone"
|
||||||
|
label="Téléphone"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.street"
|
||||||
|
label="Rue"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.city"
|
||||||
|
label="Ville"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.postalCode"
|
||||||
|
label="Code Postal"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -25,8 +50,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
|
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||||
import { useClientService } from '~/modules/directory/services/clients'
|
import { useClientService } from '~/services/clients'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -48,16 +73,37 @@ const isSubmitting = ref(false)
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
street: '',
|
||||||
|
city: '',
|
||||||
|
postalCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const touched = reactive({
|
const touched = reactive({
|
||||||
name: false,
|
name: false,
|
||||||
|
email: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
watch(() => props.modelValue, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
form.name = props.client?.name ?? ''
|
if (props.client) {
|
||||||
|
form.name = props.client.name ?? ''
|
||||||
|
form.email = props.client.email ?? ''
|
||||||
|
form.phone = props.client.phone ?? ''
|
||||||
|
form.street = props.client.street ?? ''
|
||||||
|
form.city = props.client.city ?? ''
|
||||||
|
form.postalCode = props.client.postalCode ?? ''
|
||||||
|
} else {
|
||||||
|
form.name = ''
|
||||||
|
form.email = ''
|
||||||
|
form.phone = ''
|
||||||
|
form.street = ''
|
||||||
|
form.city = ''
|
||||||
|
form.postalCode = ''
|
||||||
|
}
|
||||||
touched.name = false
|
touched.name = false
|
||||||
|
touched.email = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -71,6 +117,11 @@ async function handleSubmit() {
|
|||||||
try {
|
try {
|
||||||
const payload: ClientWrite = {
|
const payload: ClientWrite = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
email: form.email.trim() || null,
|
||||||
|
phone: form.phone.trim() || null,
|
||||||
|
street: form.street.trim() || null,
|
||||||
|
city: form.city.trim() || null,
|
||||||
|
postalCode: form.postalCode.trim() || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing.value && props.client) {
|
if (isEditing.value && props.client) {
|
||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
|
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useMailService } from '~/modules/mail/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
import { useAuthStore } from '~/shared/stores/auth'
|
import { useAuthStore } from '~/shared/stores/auth'
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
|
import type { MailFolderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Arbre de dossiers (getter folderTree du store) */
|
/** Arbre de dossiers (getter folderTree du store) */
|
||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import { useMailService } from '~/modules/mail/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messages: readonly MailMessageHeaderDto[]
|
messages: readonly MailMessageHeaderDto[]
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
|
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||||
import { useMailService } from '~/modules/mail/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** Détail complet du message. null = aucun message sélectionné. */
|
/** Détail complet du message. null = aucun message sélectionné. */
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMailStore } from '~/modules/mail/stores/mail'
|
import { useMailStore } from '~/stores/mail'
|
||||||
|
|
||||||
const store = useMailStore()
|
const store = useMailStore()
|
||||||
const { syncing } = storeToRefs(store)
|
const { syncing } = storeToRefs(store)
|
||||||
+7
-7
@@ -123,13 +123,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||||
import type { Client } from '~/modules/directory/services/dto/client'
|
import type { Client } from '~/services/dto/client'
|
||||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
import { useBookStackService } from '~/services/bookstack'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+4
-4
@@ -67,10 +67,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { stripRichText } from '~/utils/format'
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
+6
-6
@@ -82,12 +82,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -167,8 +167,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FileEntry } from '~/modules/integration/services/dto/share'
|
import type { FileEntry } from '~/services/dto/share'
|
||||||
import { useShareService } from '~/modules/integration/services/share'
|
import { useShareService } from '~/services/share'
|
||||||
import { formatFileSize } from '~/utils/format'
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
statuses: TaskStatus[]
|
statuses: TaskStatus[]
|
||||||
+2
-2
@@ -75,8 +75,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
|
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
import { useBookStackService } from '~/services/bookstack'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId: number
|
taskId: number
|
||||||
+6
-6
@@ -104,13 +104,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
+1
-1
@@ -102,7 +102,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
+2
-2
@@ -60,8 +60,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import { formatFileSize } from '~/utils/format'
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
+2
-2
@@ -121,8 +121,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import { formatFileSize } from '~/utils/format'
|
import { formatFileSize } from '~/utils/format'
|
||||||
import { copyToClipboard } from '~/utils/clipboard'
|
import { copyToClipboard } from '~/utils/clipboard'
|
||||||
|
|
||||||
+3
-3
@@ -56,9 +56,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
|
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||||
import { useShareService } from '~/modules/integration/services/share'
|
import { useShareService } from '~/services/share'
|
||||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import { formatFileSize } from '~/utils/format'
|
import { formatFileSize } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
+1
-1
@@ -46,7 +46,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
taskId?: number
|
taskId?: number
|
||||||
+2
-2
@@ -25,8 +25,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
|
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
|
||||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+3
-3
@@ -226,9 +226,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea'
|
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { copyToClipboard } from '~/utils/clipboard'
|
import { copyToClipboard } from '~/utils/clipboard'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
+4
-4
@@ -56,10 +56,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
|
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+1
-1
@@ -110,7 +110,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
+16
-16
@@ -536,23 +536,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
|
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/services/gitea'
|
||||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
import type { TaskGroup } from '~/services/dto/task-group'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
|
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||||
|
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import { useMailService } from '~/modules/mail/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -569,7 +569,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
(e: 'saved', task?: Task): void
|
(e: 'saved'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
|
|||||||
await removeRecurrence(props.task.recurrence.id)
|
await removeRecurrence(props.task.recurrence.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('saved', savedTask)
|
emit('saved')
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
+2
-2
@@ -28,8 +28,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
|
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
|
||||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+2
-2
@@ -28,8 +28,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+1
-1
@@ -64,7 +64,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: TimeEntry
|
entry: TimeEntry
|
||||||
+1
-1
@@ -35,7 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
+4
-4
@@ -124,11 +124,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry'
|
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
|
import { useTimeEntryService } from '~/services/time-entries'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
+1
-1
@@ -67,7 +67,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
import { stripRichText } from '~/utils/format'
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
+2
-2
@@ -150,8 +150,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
import { useAbsenceService } from '~/services/absences'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const absenceService = useAbsenceService()
|
const absenceService = useAbsenceService()
|
||||||
+3
-3
@@ -108,9 +108,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
import type { Client } from '~/modules/directory/services/dto/client'
|
import type { Client } from '~/services/dto/client'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
users: UserData[]
|
users: UserData[]
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
<div class="flex h-full items-center justify-between">
|
<div class="flex h-full items-center justify-between">
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:menu"
|
icon="mdi:menu"
|
||||||
aria-label="Replier ou déplier le menu"
|
aria-label="Menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||||
@click="ui.toggleSidebar()"
|
@click="ui.openMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
<div class="hidden items-center gap-2 lg:flex">
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport v-if="modelValue" to="body">
|
|
||||||
<Transition name="modal" appear>
|
|
||||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ $t('directory.reports.confirmDeleteMessage') }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="cancel"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
:label="$t('common.delete')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="$emit('confirm')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
// Suppression en cours : on désactive les actions pour éviter un double envoi.
|
|
||||||
busy?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
if (props.busy) return
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- Entête de page standard : source unique du style des titres.
|
|
||||||
Toujours sticky en haut du <main> scrollable : reste visible au scroll.
|
|
||||||
Fond blanc + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu
|
|
||||||
défilant soit masqué sous l'entête (espaces haut ET bas compris) et que
|
|
||||||
l'entête soit collée sous l'AppTopNav sans trou.
|
|
||||||
Slots :
|
|
||||||
- défaut : texte du titre
|
|
||||||
- #actions : boutons à droite du titre
|
|
||||||
- #subheader : barre de filtres / onglets rendue SOUS le titre, dans le
|
|
||||||
même bloc sticky (reste donc collée avec le titre). La
|
|
||||||
marge titre -> sous-entête est portée par le contenu passé
|
|
||||||
(ex. mt-4) pour laisser chaque page régler son cas. -->
|
|
||||||
<div class="sticky top-0 z-20 bg-white pt-[38px] pb-[30px]">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<h1 class="text-[30px] font-semibold text-primary-500">
|
|
||||||
<slot/>
|
|
||||||
</h1>
|
|
||||||
<div v-if="$slots.actions" class="shrink-0">
|
|
||||||
<slot name="actions"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<slot name="subheader"/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="to"
|
||||||
|
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
||||||
|
:class="linkClasses"
|
||||||
|
:active-class="exact ? '' : activeClass"
|
||||||
|
:exact-active-class="exact ? activeClass : ''"
|
||||||
|
>
|
||||||
|
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
v-if="!collapsed"
|
||||||
|
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
||||||
|
:class="sub ? 'text-sm' : 'text-md'"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="collapsed"
|
||||||
|
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
to: string
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
collapsed: boolean
|
||||||
|
sub?: boolean
|
||||||
|
exact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeClass = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return '!text-primary-500 bg-primary-500/10'
|
||||||
|
}
|
||||||
|
return '!text-primary-500 bg-tertiary-500'
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkClasses = computed(() => {
|
||||||
|
if (props.collapsed) {
|
||||||
|
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
||||||
|
}
|
||||||
|
if (props.sub) {
|
||||||
|
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
||||||
|
}
|
||||||
|
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -106,33 +106,15 @@ const touched = reactive({
|
|||||||
password: false,
|
password: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { create, update, getById } = useUserService()
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
function applyUser(user: UserData) {
|
|
||||||
form.username = user.username ?? ''
|
|
||||||
form.firstName = user.firstName ?? ''
|
|
||||||
form.lastName = user.lastName ?? ''
|
|
||||||
form.password = ''
|
|
||||||
form.roles = [...user.roles]
|
|
||||||
form.isEmployee = user.isEmployee ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.modelValue, async (open) => {
|
|
||||||
if (!open) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
touched.username = false
|
|
||||||
touched.password = false
|
|
||||||
|
|
||||||
if (props.item) {
|
if (props.item) {
|
||||||
applyUser(props.item)
|
form.username = props.item.username ?? ''
|
||||||
try {
|
form.firstName = props.item.firstName ?? ''
|
||||||
const full = await getById(props.item.id)
|
form.lastName = props.item.lastName ?? ''
|
||||||
applyUser(full)
|
form.password = ''
|
||||||
} catch {
|
form.roles = [...props.item.roles]
|
||||||
// Keep the list data if the detailed fetch fails.
|
form.isEmployee = props.item.isEmployee ?? false
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
form.username = ''
|
form.username = ''
|
||||||
form.firstName = ''
|
form.firstName = ''
|
||||||
@@ -141,8 +123,13 @@ watch(() => props.modelValue, async (open) => {
|
|||||||
form.roles = ['ROLE_USER']
|
form.roles = ['ROLE_USER']
|
||||||
form.isEmployee = false
|
form.isEmployee = false
|
||||||
}
|
}
|
||||||
|
touched.username = false
|
||||||
|
touched.password = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { create, update } = useUserService()
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
touched.username = true
|
touched.username = true
|
||||||
touched.password = true
|
touched.password = true
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||||
|
|
||||||
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useShareService } from '~/modules/integration/services/share'
|
import { useShareService } from '~/services/share'
|
||||||
|
|
||||||
export function useShareStatus() {
|
export function useShareStatus() {
|
||||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||||
@@ -24,9 +24,7 @@
|
|||||||
"updated": "Client mis à jour avec succès.",
|
"updated": "Client mis à jour avec succès.",
|
||||||
"deleted": "Client supprimé avec succès.",
|
"deleted": "Client supprimé avec succès.",
|
||||||
"addClient": "Ajouter un client",
|
"addClient": "Ajouter un client",
|
||||||
"editClient": "Modifier un client",
|
"editClient": "Modifier un client"
|
||||||
"deleteConfirmTitle": "Supprimer le client",
|
|
||||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le client « {name} » ? Cette action est irréversible."
|
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "Projets",
|
"title": "Projets",
|
||||||
@@ -197,57 +195,6 @@
|
|||||||
"addUser": "Ajouter un utilisateur",
|
"addUser": "Ajouter un utilisateur",
|
||||||
"editUser": "Modifier un utilisateur"
|
"editUser": "Modifier un utilisateur"
|
||||||
},
|
},
|
||||||
"admin": {
|
|
||||||
"roles": {
|
|
||||||
"title": "Rôles",
|
|
||||||
"addRole": "Ajouter un rôle",
|
|
||||||
"editRole": "Modifier un rôle",
|
|
||||||
"empty": "Aucun rôle trouvé.",
|
|
||||||
"system": "Système",
|
|
||||||
"code": "Code",
|
|
||||||
"codeHint": "Identifiant technique en snake_case (immuable).",
|
|
||||||
"codeImmutable": "Le code ne peut pas être modifié après création.",
|
|
||||||
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
|
|
||||||
"label": "Libellé",
|
|
||||||
"labelRequired": "Le libellé est requis.",
|
|
||||||
"description": "Description",
|
|
||||||
"permissions": "Permissions",
|
|
||||||
"noPermissions": "Aucune permission disponible.",
|
|
||||||
"created": "Rôle créé avec succès.",
|
|
||||||
"updated": "Rôle mis à jour avec succès.",
|
|
||||||
"deleted": "Rôle supprimé avec succès."
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"title": "Audit",
|
|
||||||
"empty": "Aucune entrée d'audit trouvée.",
|
|
||||||
"date": "Date",
|
|
||||||
"performedBy": "Utilisateur",
|
|
||||||
"entityType": "Type d'entité",
|
|
||||||
"action": "Action",
|
|
||||||
"entityId": "Identifiant",
|
|
||||||
"filterEntityType": "Type d'entité",
|
|
||||||
"filterEntityTypeAll": "Tous les types",
|
|
||||||
"filterAction": "Action",
|
|
||||||
"filterActionAll": "Toutes les actions",
|
|
||||||
"previous": "Précédent",
|
|
||||||
"next": "Suivant",
|
|
||||||
"page": "Page {page}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"entity": {
|
|
||||||
"core": {
|
|
||||||
"User": "Utilisateur",
|
|
||||||
"Role": "Rôle",
|
|
||||||
"Permission": "Permission"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"action": {
|
|
||||||
"create": "Création",
|
|
||||||
"update": "Modification",
|
|
||||||
"delete": "Suppression"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"timeEntries": {
|
"timeEntries": {
|
||||||
"created": "Temps enregistré",
|
"created": "Temps enregistré",
|
||||||
"updated": "Temps modifié",
|
"updated": "Temps modifié",
|
||||||
@@ -349,75 +296,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"myTasks": "Mes tâches",
|
||||||
"general": {
|
"general": {
|
||||||
"section": "Général",
|
"section": "Gestion de projet",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"myTasks": "Mes tâches",
|
"myTasks": "Mes tâches",
|
||||||
"projects": "Projets",
|
"projects": "Projets",
|
||||||
"timeTracking": "Suivi de temps",
|
"timeTracking": "Suivi de temps"
|
||||||
"mail": "Messagerie",
|
|
||||||
"myAbsences": "Mes absences"
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
"section": "Outils"
|
|
||||||
},
|
|
||||||
"project": {
|
|
||||||
"kanban": "Kanban",
|
|
||||||
"groups": "Groupes",
|
|
||||||
"archives": "Archives"
|
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"section": "Administration",
|
"section": "Administration",
|
||||||
"teamAbsences": "Absences équipe",
|
"teamAbsences": "Absences équipe",
|
||||||
"directory": "Répertoire",
|
|
||||||
"reporting": "Rapports",
|
|
||||||
"administration": "Administration"
|
"administration": "Administration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reporting": {
|
|
||||||
"title": "Rapports",
|
|
||||||
"export": "Exporter (CSV)",
|
|
||||||
"empty": "Aucune donnée pour cette période.",
|
|
||||||
"filters": {
|
|
||||||
"period": "Période",
|
|
||||||
"from": "Du",
|
|
||||||
"to": "Au",
|
|
||||||
"project": "Projet",
|
|
||||||
"allProjects": "Tous les projets",
|
|
||||||
"user": "Utilisateur",
|
|
||||||
"allUsers": "Tous les utilisateurs"
|
|
||||||
},
|
|
||||||
"periods": {
|
|
||||||
"thisMonth": "Mois courant",
|
|
||||||
"lastMonth": "Mois dernier",
|
|
||||||
"custom": "Personnalisé"
|
|
||||||
},
|
|
||||||
"sections": {
|
|
||||||
"timePerProject": "Temps par projet",
|
|
||||||
"timePerUser": "Temps par utilisateur",
|
|
||||||
"tasksByStatus": "Tâches par statut",
|
|
||||||
"absencesByType": "Absences par type"
|
|
||||||
},
|
|
||||||
"columns": {
|
|
||||||
"project": "Projet",
|
|
||||||
"code": "Code",
|
|
||||||
"user": "Utilisateur",
|
|
||||||
"status": "Statut",
|
|
||||||
"type": "Type",
|
|
||||||
"hours": "Heures",
|
|
||||||
"entries": "Nb saisies",
|
|
||||||
"count": "Nombre",
|
|
||||||
"days": "Jours"
|
|
||||||
},
|
|
||||||
"absenceTypes": {
|
|
||||||
"cp": "Congés payés",
|
|
||||||
"mariage_pacs": "Mariage / PACS",
|
|
||||||
"naissance": "Naissance",
|
|
||||||
"conge_parental": "Congé parental",
|
|
||||||
"deces": "Décès proche",
|
|
||||||
"maladie": "Arrêt maladie"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
@@ -433,10 +325,7 @@
|
|||||||
"thisWeek": "Cette semaine",
|
"thisWeek": "Cette semaine",
|
||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
"day": "Jour",
|
"day": "Jour",
|
||||||
"weekShort": "Sem.",
|
"weekShort": "Sem."
|
||||||
"submit": "Soumettre",
|
|
||||||
"close": "Fermer",
|
|
||||||
"back": "Retour"
|
|
||||||
},
|
},
|
||||||
"gitea": {
|
"gitea": {
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -908,156 +797,5 @@
|
|||||||
"balanceAdjusted": "Solde ajusté.",
|
"balanceAdjusted": "Solde ajusté.",
|
||||||
"policyUpdated": "Politique mise à jour."
|
"policyUpdated": "Politique mise à jour."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"prospects": {
|
|
||||||
"created": "Prospect créé avec succès.",
|
|
||||||
"updated": "Prospect mis à jour avec succès.",
|
|
||||||
"deleted": "Prospect supprimé avec succès.",
|
|
||||||
"converted": "Prospect converti en client avec succès.",
|
|
||||||
"addProspect": "Ajouter un prospect",
|
|
||||||
"editProspect": "Modifier un prospect",
|
|
||||||
"convert": "Convertir en client",
|
|
||||||
"alreadyConverted": "Déjà converti en client",
|
|
||||||
"deleteConfirmTitle": "Supprimer le prospect",
|
|
||||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nom",
|
|
||||||
"company": "Société",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Téléphone",
|
|
||||||
"website": "Site web",
|
|
||||||
"street": "Rue",
|
|
||||||
"city": "Ville",
|
|
||||||
"postalCode": "Code postal",
|
|
||||||
"status": "Statut",
|
|
||||||
"source": "Source",
|
|
||||||
"notes": "Notes"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"new": "Nouveau",
|
|
||||||
"contacted": "Contacté",
|
|
||||||
"qualified": "Qualifié",
|
|
||||||
"won": "Gagné",
|
|
||||||
"lost": "Perdu"
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"nameRequired": "Le nom est requis",
|
|
||||||
"companyRequired": "La société est requise"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"prestataires": {
|
|
||||||
"created": "Prestataire créé avec succès.",
|
|
||||||
"updated": "Prestataire mis à jour avec succès.",
|
|
||||||
"deleted": "Prestataire supprimé avec succès.",
|
|
||||||
"addPrestataire": "Ajouter un prestataire",
|
|
||||||
"editPrestataire": "Modifier un prestataire",
|
|
||||||
"deleteConfirmTitle": "Supprimer le prestataire",
|
|
||||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prestataire « {name} » ? Cette action est irréversible.",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nom / Société",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Téléphone",
|
|
||||||
"website": "Site web"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"directory": {
|
|
||||||
"title": "Répertoire",
|
|
||||||
"tabs": {
|
|
||||||
"info": "Informations",
|
|
||||||
"clients": "Clients",
|
|
||||||
"prospects": "Prospects",
|
|
||||||
"prestataires": "Prestataires",
|
|
||||||
"contact": "Contact",
|
|
||||||
"address": "Adresse",
|
|
||||||
"report": "Rapport"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"fields": {
|
|
||||||
"name": "Nom",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Téléphone",
|
|
||||||
"website": "Site web"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"nameRequired": "Le nom est requis.",
|
|
||||||
"subjectRequired": "L'objet est requis.",
|
|
||||||
"emailInvalid": "Adresse email invalide.",
|
|
||||||
"phoneInvalid": "Numéro de téléphone invalide (ex. 0549200910).",
|
|
||||||
"urlInvalid": "URL invalide (ex. https://exemple.fr)."
|
|
||||||
},
|
|
||||||
"clients": {
|
|
||||||
"add": "Ajouter un client",
|
|
||||||
"empty": "Aucun client trouvé."
|
|
||||||
},
|
|
||||||
"prospects": {
|
|
||||||
"add": "Ajouter un prospect",
|
|
||||||
"empty": "Aucun prospect trouvé.",
|
|
||||||
"allStatuses": "Tous les statuts"
|
|
||||||
},
|
|
||||||
"prestataires": {
|
|
||||||
"add": "Ajouter un prestataire",
|
|
||||||
"empty": "Aucun prestataire trouvé."
|
|
||||||
},
|
|
||||||
"contacts": {
|
|
||||||
"add": "Ajouter un contact",
|
|
||||||
"item": "Contact {n}",
|
|
||||||
"saved": "Contact enregistré.",
|
|
||||||
"deleted": "Contact supprimé.",
|
|
||||||
"fields": {
|
|
||||||
"lastName": "Nom",
|
|
||||||
"firstName": "Prénom",
|
|
||||||
"jobTitle": "Fonction",
|
|
||||||
"email": "Email",
|
|
||||||
"phonePrimary": "Téléphone",
|
|
||||||
"phoneSecondary": "Téléphone secondaire"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"addresses": {
|
|
||||||
"add": "Ajouter une adresse",
|
|
||||||
"item": "Adresse {n}",
|
|
||||||
"saved": "Adresse enregistrée.",
|
|
||||||
"deleted": "Adresse supprimée.",
|
|
||||||
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
|
|
||||||
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
|
|
||||||
"fields": {
|
|
||||||
"label": "Libellé",
|
|
||||||
"street": "Rue",
|
|
||||||
"streetComplement": "Complément",
|
|
||||||
"postalCode": "Code postal",
|
|
||||||
"city": "Ville"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reports": {
|
|
||||||
"add": "Ajouter un compte-rendu",
|
|
||||||
"addTitle": "Nouveau compte-rendu",
|
|
||||||
"editTitle": "Modifier le compte-rendu",
|
|
||||||
"empty": "Aucun compte-rendu",
|
|
||||||
"emptyHint": "Consignez vos échanges (appels, rendez-vous, emails) pour garder l'historique de la relation.",
|
|
||||||
"count": "{n} compte-rendu | {n} comptes-rendus",
|
|
||||||
"documentsLabel": "Documents",
|
|
||||||
"saved": "Compte-rendu enregistré.",
|
|
||||||
"deleted": "Compte-rendu supprimé.",
|
|
||||||
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
|
|
||||||
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
|
|
||||||
"fields": {
|
|
||||||
"subject": "Objet",
|
|
||||||
"type": "Type d'échange",
|
|
||||||
"occurredAt": "Date",
|
|
||||||
"body": "Compte-rendu"
|
|
||||||
},
|
|
||||||
"types": {
|
|
||||||
"call": "Appel",
|
|
||||||
"meeting": "Rendez-vous",
|
|
||||||
"email": "Email",
|
|
||||||
"note": "Note"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"documents": {
|
|
||||||
"add": "Joindre un document",
|
|
||||||
"uploading": "Envoi…",
|
|
||||||
"empty": "Aucun document.",
|
|
||||||
"deleted": "Document supprimé."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export default defineNuxtConfig({})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export function usePermissions() {
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
function isAdmin(): boolean {
|
|
||||||
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
function can(code: string): boolean {
|
|
||||||
if (!auth.user) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (isAdmin()) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return auth.user.effectivePermissions?.includes(code) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
function canAny(codes: string[]): boolean {
|
|
||||||
return codes.some((c) => can(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
function canAll(codes: string[]): boolean {
|
|
||||||
return codes.every((c) => can(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { can, canAny, canAll, isAdmin }
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="default">
|
<NuxtLayout name="default">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<div class="mx-auto max-w-lg px-4 py-10">
|
||||||
<PageHeader>{{ $t('profile.title') }}</PageHeader>
|
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
<!-- Current avatar -->
|
<!-- Current avatar -->
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { HydraCollection } from '~/utils/api'
|
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
|
||||||
|
|
||||||
export type AuditLogAction = 'create' | 'update' | 'delete'
|
|
||||||
|
|
||||||
export type AuditLogItem = {
|
|
||||||
id: string
|
|
||||||
'@id'?: string
|
|
||||||
entityType: string
|
|
||||||
entityId: string
|
|
||||||
action: AuditLogAction
|
|
||||||
changes: Record<string, unknown>
|
|
||||||
performedBy: string
|
|
||||||
performedAt: string
|
|
||||||
ipAddress: string | null
|
|
||||||
requestId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuditLogQuery = {
|
|
||||||
page?: number
|
|
||||||
entityType?: string
|
|
||||||
action?: AuditLogAction
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuditLogPage = {
|
|
||||||
items: AuditLogItem[]
|
|
||||||
totalItems: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuditLogEntityTypes = {
|
|
||||||
'@id'?: string
|
|
||||||
entityTypes: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuditLogService() {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
async function list(params: AuditLogQuery = {}): Promise<AuditLogPage> {
|
|
||||||
const query: Record<string, unknown> = {}
|
|
||||||
if (params.page !== undefined) {
|
|
||||||
query.page = params.page
|
|
||||||
}
|
|
||||||
if (params.entityType) {
|
|
||||||
query.entity_type = params.entityType
|
|
||||||
}
|
|
||||||
if (params.action) {
|
|
||||||
query.action = params.action
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await api.get<HydraCollection<AuditLogItem>>('/audit-logs', query)
|
|
||||||
return {
|
|
||||||
items: extractHydraMembers(data),
|
|
||||||
totalItems: data['hydra:totalItems'] ?? data['totalItems'] ?? 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function entityTypes(): Promise<string[]> {
|
|
||||||
// `/audit-log-entity-types` is a single API Platform item resource
|
|
||||||
// (not a hydra collection): it returns `{ entityTypes: string[] }`.
|
|
||||||
const data = await api.get<AuditLogEntityTypes>('/audit-log-entity-types')
|
|
||||||
return data.entityTypes ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
return { list, entityTypes }
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { HydraCollection } from '~/utils/api'
|
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
|
||||||
|
|
||||||
export type Permission = {
|
|
||||||
id: number
|
|
||||||
'@id'?: string
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
module: string
|
|
||||||
orphan?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePermissionService() {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
async function list(): Promise<Permission[]> {
|
|
||||||
const data = await api.get<HydraCollection<Permission>>('/permissions')
|
|
||||||
return extractHydraMembers(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { list }
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { Permission } from './permissions'
|
|
||||||
import type { HydraCollection } from '~/utils/api'
|
|
||||||
import { extractHydraMembers } from '~/utils/api'
|
|
||||||
|
|
||||||
export type Role = {
|
|
||||||
id: number
|
|
||||||
'@id'?: string
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
description?: string | null
|
|
||||||
isSystem: boolean
|
|
||||||
permissions: Permission[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RoleWrite = {
|
|
||||||
code?: string
|
|
||||||
label: string
|
|
||||||
description?: string | null
|
|
||||||
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
|
|
||||||
permissions: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRoleService() {
|
|
||||||
const api = useApi()
|
|
||||||
|
|
||||||
async function list(): Promise<Role[]> {
|
|
||||||
const data = await api.get<HydraCollection<Role>>('/roles')
|
|
||||||
return extractHydraMembers(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create(payload: RoleWrite): Promise<Role> {
|
|
||||||
return api.post<Role>('/roles', payload as Record<string, unknown>, {
|
|
||||||
toastSuccessKey: 'admin.roles.created',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
|
|
||||||
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
|
|
||||||
toastSuccessKey: 'admin.roles.updated',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(id: number): Promise<void> {
|
|
||||||
await api.delete(`/roles/${id}`, {}, {
|
|
||||||
toastSuccessKey: 'admin.roles.deleted',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { list, create, update, remove }
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MalioDrawer v-model="isOpen">
|
|
||||||
<template #header>
|
|
||||||
<h2 class="text-xl font-bold">
|
|
||||||
{{ isEditing ? $t('directory.reports.editTitle') : $t('directory.reports.addTitle') }}
|
|
||||||
</h2>
|
|
||||||
</template>
|
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.subject"
|
|
||||||
:label="$t('directory.reports.fields.subject')"
|
|
||||||
input-class="w-full"
|
|
||||||
:error="touched.subject && !form.subject.trim() ? $t('directory.validation.subjectRequired') : ''"
|
|
||||||
@blur="touched.subject = true"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="form.type"
|
|
||||||
:label="$t('directory.reports.fields.type')"
|
|
||||||
:options="typeOptions"
|
|
||||||
group-class="w-full"
|
|
||||||
/>
|
|
||||||
<MalioDate
|
|
||||||
v-model="form.occurredAt"
|
|
||||||
:label="$t('directory.reports.fields.occurredAt')"
|
|
||||||
/>
|
|
||||||
<MalioInputRichText
|
|
||||||
v-model="form.body"
|
|
||||||
:label="$t('directory.reports.fields.body')"
|
|
||||||
min-height="180px"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
@click="isOpen = false"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</MalioDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
|
||||||
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
report: CommercialReport | null
|
|
||||||
owner: { client?: string, prospect?: string, prestataire?: string }
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { create, update } = useCommercialReportService()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.report)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const typeOptions: { label: string, value: ReportType }[] = [
|
|
||||||
{ label: t('directory.reports.types.call'), value: 'call' },
|
|
||||||
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
|
|
||||||
{ label: t('directory.reports.types.email'), value: 'email' },
|
|
||||||
{ label: t('directory.reports.types.note'), value: 'note' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function today(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// L'éditeur riche émet du HTML : un contenu « vide » vaut `<p></p>`. On le
|
|
||||||
// normalise en null pour ne pas persister une coquille vide.
|
|
||||||
function normalizeBody(html: string): string | null {
|
|
||||||
const stripped = html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim()
|
|
||||||
return stripped ? html : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = reactive<{ subject: string, type: ReportType, occurredAt: string, body: string }>({
|
|
||||||
subject: '',
|
|
||||||
type: 'note',
|
|
||||||
occurredAt: today(),
|
|
||||||
body: '',
|
|
||||||
})
|
|
||||||
const touched = reactive({ subject: false })
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (!open) return
|
|
||||||
if (props.report) {
|
|
||||||
form.subject = props.report.subject
|
|
||||||
form.type = props.report.type
|
|
||||||
form.occurredAt = props.report.occurredAt.slice(0, 10)
|
|
||||||
form.body = props.report.body ?? ''
|
|
||||||
} else {
|
|
||||||
form.subject = ''
|
|
||||||
form.type = 'note'
|
|
||||||
form.occurredAt = today()
|
|
||||||
form.body = ''
|
|
||||||
}
|
|
||||||
touched.subject = false
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(): Promise<void> {
|
|
||||||
touched.subject = true
|
|
||||||
if (!form.subject.trim() || isSubmitting.value) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const payload: CommercialReportWrite = {
|
|
||||||
subject: form.subject.trim(),
|
|
||||||
type: form.type,
|
|
||||||
occurredAt: form.occurredAt,
|
|
||||||
body: normalizeBody(form.body),
|
|
||||||
...props.owner,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value && props.report) {
|
|
||||||
await update(props.report.id, payload)
|
|
||||||
} else {
|
|
||||||
await create(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user