Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee9b751a1f | |||
| bfbab5bbf2 | |||
| bf55a55fa6 | |||
| f6d37e4667 | |||
| 1589908e4c | |||
| 6aa099cd28 | |||
| cd7cb93bac | |||
| 21eeb36766 | |||
| fb8cc790d7 | |||
| e19e392adb | |||
| bf7253c52f | |||
| f90d30975e | |||
| 00b9677e8e | |||
| 1bd9d5bc56 | |||
| dd78f6c275 | |||
| bc9c036d1f | |||
| d95f14dadc | |||
| 354d7c34ba | |||
| 33ba90a00d | |||
| b9538454a9 | |||
| 5af529d1b2 | |||
| 8d63735bd8 | |||
| e5a64a60c4 | |||
| bf263f4c63 | |||
| a18e1f575f | |||
| 8a5b115ccd | |||
| 478b5ec15d | |||
| 987df54175 | |||
| a76facbf4c | |||
| 62cdd4614a | |||
| 4a9977e199 | |||
| a547fd38c2 | |||
| 96ef1bf436 | |||
| da3d190216 | |||
| 0cce586a1f | |||
| 144a8a4685 | |||
| a2bbc8311d | |||
| 808a290845 | |||
| f4ffc02028 | |||
| b3b29fd753 | |||
| 0761bbd8c1 | |||
| 90682e809c | |||
| bb7d7e7953 | |||
| 25d3a693f9 | |||
| 57ccd9a740 | |||
| d42b288434 | |||
| c5738d269b | |||
| 163bf0891a | |||
| 306cfd34cd | |||
| 7446b7dca9 | |||
| c90d91d6c4 | |||
| 23809f165e | |||
| f119ec30ca | |||
| 1b652ef680 | |||
| d1516c3f5d | |||
| a88cb1bc35 | |||
| 7686904c43 | |||
| 9b26b43aca | |||
| e7af415a1f | |||
| 90b8ca15cd | |||
| 8c3699a9b0 | |||
| d8553f06f5 | |||
| 934cf0835f | |||
| fda03bd1f5 | |||
| 4760c386ed | |||
| 511353c3f5 | |||
| 544d4cf44f | |||
| 1a9eba93a0 | |||
| 48c67a5fb9 | |||
| 5060fb689b | |||
| ac662e701b | |||
| ffed224979 | |||
| fdc72573ea | |||
| 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 |
@@ -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`)
|
||||
- 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
|
||||
|
||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||
|
||||
@@ -113,14 +113,6 @@ services:
|
||||
|
||||
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 }
|
||||
|
||||
+5
-12
@@ -23,17 +23,10 @@ return [
|
||||
'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.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
|
||||
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
|
||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||
],
|
||||
],
|
||||
@@ -44,8 +37,8 @@ return [
|
||||
'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'],
|
||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.40'
|
||||
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..."
|
||||
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..."
|
||||
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
|
||||
@@ -300,31 +294,7 @@ cd /var/www/lesstime
|
||||
./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
|
||||
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
|
||||
```
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
+141
-126
@@ -1,31 +1,112 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="mergedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
||||
<img
|
||||
v-if="!sidebarIsCollapsed"
|
||||
src="/malio.png"
|
||||
alt="Logo"
|
||||
class="w-auto"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
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>
|
||||
<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>
|
||||
<!-- Feature-flag : Documents -->
|
||||
<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>
|
||||
</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">
|
||||
<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/>
|
||||
</main>
|
||||
</div>
|
||||
@@ -58,6 +139,23 @@ const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
// `/mail` est déclaré dans config/sidebar.php pour le gating module (disabledRoutes),
|
||||
// mais son rendu visuel + badge non-lus est géré manuellement ci-dessous (feature-flag Mail).
|
||||
// On le filtre des sections dynamiques pour éviter un doublon dans la nav.
|
||||
const translatedSections = computed(() =>
|
||||
sections.value.map((section) => ({
|
||||
label: t(section.label),
|
||||
icon: section.icon,
|
||||
items: section.items
|
||||
.filter((item) => item.to !== '/mail')
|
||||
.map((item) => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
@@ -68,116 +166,22 @@ const isMailVisible = computed(() => {
|
||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||
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 match = route.path.match(/^\/projects\/(\d+)/)
|
||||
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 baseTitle = ref('Lesstime')
|
||||
@@ -265,3 +269,14 @@ function onCompleteSaved() {
|
||||
})
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:menu"
|
||||
aria-label="Replier ou déplier le menu"
|
||||
aria-label="Menu"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
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">
|
||||
<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>
|
||||
@@ -24,9 +24,7 @@
|
||||
"updated": "Client mis à jour avec succès.",
|
||||
"deleted": "Client supprimé avec succès.",
|
||||
"addClient": "Ajouter 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."
|
||||
"editClient": "Modifier un client"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projets",
|
||||
@@ -349,29 +347,21 @@
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"myTasks": "Mes tâches",
|
||||
"general": {
|
||||
"section": "Général",
|
||||
"section": "Gestion de projet",
|
||||
"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"
|
||||
"mail": "Messagerie"
|
||||
},
|
||||
"admin": {
|
||||
"section": "Administration",
|
||||
"teamAbsences": "Absences équipe",
|
||||
"administration": "Administration",
|
||||
"directory": "Répertoire",
|
||||
"reporting": "Rapports",
|
||||
"administration": "Administration"
|
||||
"reporting": "Rapports"
|
||||
}
|
||||
},
|
||||
"reporting": {
|
||||
@@ -918,14 +908,11 @@
|
||||
"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",
|
||||
@@ -941,51 +928,18 @@
|
||||
"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"
|
||||
"nameRequired": "Le nom est requis"
|
||||
}
|
||||
},
|
||||
"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é."
|
||||
@@ -995,10 +949,6 @@
|
||||
"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}",
|
||||
@@ -1018,8 +968,6 @@
|
||||
"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",
|
||||
@@ -1030,16 +978,9 @@
|
||||
},
|
||||
"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",
|
||||
"empty": "Aucun compte-rendu.",
|
||||
"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",
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ $t('absences.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
:label="$t('absences.newRequest')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="requestDrawerOpen = true"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
||||
<MalioButton
|
||||
:label="$t('absences.newRequest')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="requestDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<AbsenceBalanceCards :balances="balances" />
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -68,7 +65,6 @@
|
||||
:can-cancel="selected?.status === 'pending'"
|
||||
@cancelled="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t("absences.teamTitle") }}
|
||||
</PageHeader>
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
@@ -190,7 +189,6 @@
|
||||
:user="selectedEmployee"
|
||||
@saved="loadEmployees"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<NuxtLayout name="default">
|
||||
<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">
|
||||
<!-- Current avatar -->
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom société"
|
||||
label="Nom"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||
@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"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
@@ -48,16 +58,28 @@ const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
email: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (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 ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
}
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,6 +93,8 @@ async function handleSubmit() {
|
||||
try {
|
||||
const payload: ClientWrite = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.client) {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,235 +1,158 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5 pt-6">
|
||||
<!-- Barre d'action -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm text-neutral-500">
|
||||
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span>
|
||||
</p>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.reports.add')"
|
||||
@click="openCreate"
|
||||
<div class="flex flex-col gap-6 pt-6">
|
||||
<!-- Formulaire d'ajout / édition -->
|
||||
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.reports.fields.subject')"
|
||||
v-model="draft.subject"
|
||||
/>
|
||||
<MalioSelect
|
||||
:label="$t('directory.reports.fields.type')"
|
||||
v-model="draft.type"
|
||||
:options="typeOptions"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioDate
|
||||
:label="$t('directory.reports.fields.occurredAt')"
|
||||
v-model="draft.occurredAt"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
class="col-span-2"
|
||||
:label="$t('directory.reports.fields.body')"
|
||||
v-model="draft.body"
|
||||
/>
|
||||
<div class="col-span-2 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
v-if="editingId"
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('common.cancel')"
|
||||
@click="resetDraft"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="editingId ? $t('common.save') : $t('directory.reports.add')"
|
||||
:disabled="!draft.subject"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État vide -->
|
||||
<div
|
||||
v-if="!loading && !reports.length"
|
||||
class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-50 px-6 py-12 text-center"
|
||||
>
|
||||
<Icon name="mdi:message-text-outline" class="text-4xl text-neutral-300" />
|
||||
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p>
|
||||
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="mt-2 w-auto px-4"
|
||||
:label="$t('directory.reports.add')"
|
||||
@click="openCreate"
|
||||
/>
|
||||
<!-- Liste des comptes-rendus -->
|
||||
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
|
||||
<span v-if="report.author"> · {{ report.author.username }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="flex gap-2">
|
||||
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
|
||||
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<ReportDocumentList
|
||||
:documents="report.documents ?? []"
|
||||
:is-admin="isAdmin"
|
||||
@delete="(id) => removeDocument(report, id)"
|
||||
/>
|
||||
<ReportDocumentUpload
|
||||
v-if="isAdmin"
|
||||
:report-id="report.id"
|
||||
@uploaded="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline antéchronologique -->
|
||||
<ol v-else class="flex flex-col">
|
||||
<li
|
||||
v-for="report in sortedReports"
|
||||
:key="report.id"
|
||||
class="relative flex gap-4 pb-6 last:pb-0"
|
||||
>
|
||||
<!-- Rail + pastille de type -->
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||
:class="typeStyle(report.type).badge"
|
||||
>
|
||||
<Icon :name="typeStyle(report.type).icon" class="text-lg" />
|
||||
</span>
|
||||
<span class="mt-1 w-px grow bg-neutral-200" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Carte -->
|
||||
<div class="flex-1 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="typeStyle(report.type).chip"
|
||||
>
|
||||
{{ $t(`directory.reports.types.${report.type}`) }}
|
||||
</span>
|
||||
<p class="truncate font-semibold text-neutral-800">{{ report.subject }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-500">
|
||||
<span :title="absoluteDate(report.occurredAt)">{{ relativeDate(report.occurredAt) }}</span>
|
||||
<span v-if="report.author"> · {{ report.author.username }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="canManage" class="flex shrink-0 gap-1">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:pencil-outline"
|
||||
variant="ghost"
|
||||
:aria-label="$t('common.edit')"
|
||||
@click="openEdit(report)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
:aria-label="$t('common.delete')"
|
||||
@click="askDelete(report)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioInputRichText
|
||||
v-if="report.body"
|
||||
:model-value="report.body"
|
||||
:editable="false"
|
||||
:reserve-message-space="false"
|
||||
editor-class="!border-0 !rounded-none !bg-transparent !p-0 text-sm text-neutral-700"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<!-- Documents joints -->
|
||||
<div
|
||||
v-if="(report.documents?.length ?? 0) || canManage"
|
||||
class="mt-3 border-t border-neutral-100 pt-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{{ $t('directory.reports.documentsLabel') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<ReportDocumentList
|
||||
v-if="report.documents?.length"
|
||||
:documents="report.documents"
|
||||
:can-manage="canManage"
|
||||
@delete="(docId) => removeDocument(docId)"
|
||||
/>
|
||||
<ReportDocumentUpload
|
||||
v-if="canManage"
|
||||
:report-id="report.id"
|
||||
@uploaded="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<CommercialReportDrawer
|
||||
v-model="drawerOpen"
|
||||
:report="editing"
|
||||
:owner="owner"
|
||||
@saved="reload"
|
||||
/>
|
||||
<ConfirmDeleteReportModal
|
||||
v-model="confirmOpen"
|
||||
:busy="deleting"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
<p v-if="!reports.length" class="text-sm text-neutral-400">
|
||||
{{ $t('directory.reports.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CommercialReport, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
||||
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
||||
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
owner: { client?: string, prospect?: string, prestataire?: string }
|
||||
canManage: boolean
|
||||
owner: { client?: string, prospect?: string }
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const reportService = useCommercialReportService()
|
||||
const documentService = useReportDocumentService()
|
||||
|
||||
const reports = ref<CommercialReport[]>([])
|
||||
const loading = ref(true)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editing = ref<CommercialReport | null>(null)
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const pendingDelete = ref<CommercialReport | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Le plus récent en haut (l'API ne garantit pas l'ordre).
|
||||
const sortedReports = computed(() =>
|
||||
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
||||
)
|
||||
|
||||
const typeStyles: Record<ReportType, { icon: string, badge: string, chip: string }> = {
|
||||
call: { icon: 'mdi:phone-outline', badge: 'bg-emerald-100 text-emerald-700', chip: 'bg-emerald-50 text-emerald-700' },
|
||||
meeting: { icon: 'mdi:account-group-outline', badge: 'bg-violet-100 text-violet-700', chip: 'bg-violet-50 text-violet-700' },
|
||||
email: { icon: 'mdi:email-outline', badge: 'bg-sky-100 text-sky-700', chip: 'bg-sky-50 text-sky-700' },
|
||||
note: { icon: 'mdi:note-text-outline', badge: 'bg-amber-100 text-amber-700', chip: 'bg-amber-50 text-amber-700' },
|
||||
}
|
||||
function typeStyle(type: ReportType) {
|
||||
return typeStyles[type]
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||
}
|
||||
|
||||
function absoluteDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
// Date relative lisible (« aujourd'hui », « il y a 3 jours »…) avec repli sur la
|
||||
// date absolue au-delà d'un an. La date exacte reste disponible en infobulle.
|
||||
function relativeDate(iso: string): string {
|
||||
const diffDays = Math.round((startOfDay(new Date(iso)) - startOfDay(new Date())) / 86400000)
|
||||
const rtf = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' })
|
||||
const abs = Math.abs(diffDays)
|
||||
if (abs < 1) return rtf.format(0, 'day')
|
||||
if (abs < 7) return rtf.format(diffDays, 'day')
|
||||
if (abs < 31) return rtf.format(Math.round(diffDays / 7), 'week')
|
||||
if (abs < 365) return rtf.format(Math.round(diffDays / 30), 'month')
|
||||
return absoluteDate(iso)
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editing.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(report: CommercialReport): void {
|
||||
editing.value = report
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function askDelete(report: CommercialReport): void {
|
||||
pendingDelete.value = report
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!pendingDelete.value || deleting.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await reportService.remove(pendingDelete.value.id)
|
||||
confirmOpen.value = false
|
||||
pendingDelete.value = null
|
||||
await reload()
|
||||
} finally {
|
||||
deleting.value = false
|
||||
function emptyDraft(): CommercialReportWrite {
|
||||
return {
|
||||
subject: '',
|
||||
body: null,
|
||||
occurredAt: new Date().toISOString().slice(0, 10),
|
||||
type: 'note',
|
||||
...props.owner,
|
||||
}
|
||||
}
|
||||
const draft = ref<CommercialReportWrite>(emptyDraft())
|
||||
|
||||
async function removeDocument(id: number): Promise<void> {
|
||||
await documentService.remove(id)
|
||||
await reload()
|
||||
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 formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
reports.value = await reportService.getByOwner(props.owner)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
editingId.value = null
|
||||
draft.value = emptyDraft()
|
||||
}
|
||||
|
||||
function edit(report: CommercialReport): void {
|
||||
editingId.value = report.id
|
||||
draft.value = {
|
||||
subject: report.subject,
|
||||
body: report.body,
|
||||
occurredAt: report.occurredAt.slice(0, 10),
|
||||
type: report.type,
|
||||
...props.owner,
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (editingId.value) {
|
||||
await reportService.update(editingId.value, draft.value)
|
||||
} else {
|
||||
await reportService.create(draft.value)
|
||||
}
|
||||
resetDraft()
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await reportService.remove(id)
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function removeDocument(report: CommercialReport, id: number): Promise<void> {
|
||||
await documentService.remove(id)
|
||||
await reload()
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
||||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
class="absolute right-3 top-3"
|
||||
icon="mdi:trash-can-outline"
|
||||
class="absolute right-2 top-2"
|
||||
button-class="!text-red-600"
|
||||
:aria-label="$t('common.delete')"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
@@ -19,33 +19,13 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('label', $event)"
|
||||
/>
|
||||
|
||||
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
|
||||
allow-create conserve le texte saisi si la BAN ne propose rien
|
||||
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:allow-create="true"
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:no-results-text="$t('directory.addresses.streetNotFound')"
|
||||
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('street', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('street', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.streetComplement')"
|
||||
@@ -53,27 +33,13 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('streetComplement', $event)"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:label="$t('directory.addresses.fields.postalCode')"
|
||||
:model-value="modelValue.postalCode ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="onPostalCodeInput"
|
||||
/>
|
||||
|
||||
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
||||
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
||||
<MalioSelect
|
||||
v-if="!readonly && !degraded"
|
||||
:model-value="modelValue.city ?? ''"
|
||||
:options="cityOptions"
|
||||
:label="$t('directory.addresses.fields.city')"
|
||||
empty-option-label=""
|
||||
group-class="w-full"
|
||||
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
|
||||
@update:model-value="update('postalCode', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:label="$t('directory.addresses.fields.city')"
|
||||
:model-value="modelValue.city ?? ''"
|
||||
:readonly="readonly"
|
||||
@@ -84,10 +50,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Address } from '~/modules/directory/services/dto/address'
|
||||
import {
|
||||
useAddressAutocomplete,
|
||||
type AddressSuggestion,
|
||||
} from '~/modules/directory/composables/useAddressAutocomplete'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Address
|
||||
@@ -101,98 +63,7 @@ const emit = defineEmits<{
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
type Option = { label: string, value: string | number }
|
||||
|
||||
const addressOptions = ref<Option[]>([])
|
||||
// Villes renvoyées par la BAN pour le code postal courant.
|
||||
const fetchedCityOptions = ref<Option[]>([])
|
||||
const addressLoading = ref(false)
|
||||
|
||||
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
|
||||
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
|
||||
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
|
||||
// même avant toute recherche par code postal — sinon elle s'afficherait vide.
|
||||
const cityOptions = computed<Option[]>(() => {
|
||||
const current = (props.modelValue.city ?? '').trim()
|
||||
const options = [...fetchedCityOptions.value]
|
||||
if (current && !options.some(o => o.value === current)) {
|
||||
options.unshift({ value: current, label: current })
|
||||
}
|
||||
return options
|
||||
})
|
||||
// Mode dégradé : BAN indisponible → la ville passe en saisie libre.
|
||||
const degraded = ref(false)
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
let notified = false
|
||||
|
||||
function update(field: keyof Address, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
|
||||
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
|
||||
function notifyUnavailable(): void {
|
||||
if (notified) return
|
||||
notified = true
|
||||
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
if (query.trim().length < 3) {
|
||||
addressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
addressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
|
||||
function onAddressSelect(option: Option | null): void {
|
||||
if (option === null) return
|
||||
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
|
||||
// rue : deux communes peuvent partager le même libellé de voie.
|
||||
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city || props.modelValue.city,
|
||||
postalCode: suggestion.postalCode || props.modelValue.postalCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeInput(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) return
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
||||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
class="absolute right-3 top-3"
|
||||
icon="mdi:trash-can-outline"
|
||||
class="absolute right-2 top-2"
|
||||
button-class="!text-red-600"
|
||||
:aria-label="$t('common.delete')"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
@@ -35,21 +35,18 @@
|
||||
:label="$t('directory.contacts.fields.email')"
|
||||
:model-value="modelValue.email ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="emailError"
|
||||
@update:model-value="update('email', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phonePrimary')"
|
||||
:model-value="modelValue.phonePrimary ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="phonePrimaryError"
|
||||
@update:model-value="update('phonePrimary', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||||
:model-value="modelValue.phoneSecondary ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="phoneSecondaryError"
|
||||
@update:model-value="update('phoneSecondary', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,7 +54,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||||
import { isValidEmail, isValidFrPhone } from '~/modules/directory/utils/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Contact
|
||||
@@ -71,18 +67,6 @@ const emit = defineEmits<{
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emailError = computed(() =>
|
||||
isValidEmail(props.modelValue.email) ? '' : t('directory.validation.emailInvalid'),
|
||||
)
|
||||
const phonePrimaryError = computed(() =>
|
||||
isValidFrPhone(props.modelValue.phonePrimary) ? '' : t('directory.validation.phoneInvalid'),
|
||||
)
|
||||
const phoneSecondaryError = computed(() =>
|
||||
isValidFrPhone(props.modelValue.phoneSecondary) ? '' : t('directory.validation.phoneInvalid'),
|
||||
)
|
||||
|
||||
function update(field: keyof Contact, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('prestataires.editPrestataire') : $t('prestataires.addPrestataire') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="$t('prestataires.fields.name')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
|
||||
<div class="mt-6 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 { Prestataire, PrestataireWrite } from '~/modules/directory/services/dto/prestataire'
|
||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
prestataire: Prestataire | null
|
||||
}>()
|
||||
|
||||
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.prestataire)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
form.name = props.prestataire?.name ?? ''
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = usePrestataireService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: PrestataireWrite = {
|
||||
name: form.name.trim(),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.prestataire) {
|
||||
await update(props.prestataire.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,12 +4,42 @@
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('prospects.editProspect') : $t('prospects.addProspect') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="$t('prospects.fields.name')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.company"
|
||||
:label="$t('prospects.fields.company')"
|
||||
input-class="w-full"
|
||||
:error="touched.company && !form.company.trim() ? $t('prospects.validation.companyRequired') : ''"
|
||||
@blur="touched.company = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.email"
|
||||
:label="$t('prospects.fields.email')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.phone"
|
||||
:label="$t('prospects.fields.phone')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.status"
|
||||
:label="$t('prospects.fields.status')"
|
||||
:options="statusOptions"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.source"
|
||||
:label="$t('prospects.fields.source')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.notes"
|
||||
:label="$t('prospects.fields.notes')"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-2">
|
||||
@@ -39,7 +69,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect'
|
||||
import type { Prospect, ProspectStatus, ProspectWrite } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -52,6 +82,8 @@ const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
@@ -61,31 +93,75 @@ const isEditing = computed(() => !!props.prospect)
|
||||
const isConverted = computed(() => !!props.prospect?.convertedClient)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
const statusOptions = [
|
||||
{ label: t('prospects.status.new'), value: 'new' },
|
||||
{ label: t('prospects.status.contacted'), value: 'contacted' },
|
||||
{ label: t('prospects.status.qualified'), value: 'qualified' },
|
||||
{ label: t('prospects.status.won'), value: 'won' },
|
||||
{ label: t('prospects.status.lost'), value: 'lost' },
|
||||
]
|
||||
|
||||
const form = reactive<{
|
||||
name: string
|
||||
company: string
|
||||
email: string
|
||||
phone: string
|
||||
status: ProspectStatus
|
||||
source: string
|
||||
notes: string
|
||||
}>({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: 'new',
|
||||
source: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
company: false,
|
||||
name: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
form.company = props.prospect?.company ?? ''
|
||||
touched.company = false
|
||||
if (props.prospect) {
|
||||
form.name = props.prospect.name ?? ''
|
||||
form.company = props.prospect.company ?? ''
|
||||
form.email = props.prospect.email ?? ''
|
||||
form.phone = props.prospect.phone ?? ''
|
||||
form.status = props.prospect.status ?? 'new'
|
||||
form.source = props.prospect.source ?? ''
|
||||
form.notes = props.prospect.notes ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.company = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.status = 'new'
|
||||
form.source = ''
|
||||
form.notes = ''
|
||||
}
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, convert } = useProspectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.company = true
|
||||
if (!form.company.trim()) return
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProspectWrite = {
|
||||
company: form.company.trim(),
|
||||
name: form.name.trim(),
|
||||
company: form.company.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
status: form.status,
|
||||
source: form.source.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.prospect) {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{ doc.originalName }}
|
||||
</a>
|
||||
<MalioButtonIcon
|
||||
v-if="canManage"
|
||||
v-if="isAdmin"
|
||||
icon="mdi:trash-can-outline"
|
||||
button-class="!text-red-600"
|
||||
:aria-label="$t('common.delete')"
|
||||
@@ -32,7 +32,7 @@
|
||||
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
defineProps<{ documents: ReportDocument[], canManage: boolean }>()
|
||||
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
|
||||
defineEmits<{ delete: [id: number] }>()
|
||||
|
||||
const { getDownloadUrl } = useReportDocumentService()
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { httpExternal } from '~/utils/httpExternal'
|
||||
|
||||
// Autocomplétion d'adresse branchée sur la Base Adresse Nationale (BAN),
|
||||
// `api-adresse.data.gouv.fr` — service public français, gratuit, CORS ouvert.
|
||||
//
|
||||
// Appel HTTP DIRECT depuis le front (pas de proxy back) : la BAN est un domaine
|
||||
// externe, sans cookie de session ni enveloppe Hydra → on passe par
|
||||
// `httpExternal` et NON `useApi()`.
|
||||
//
|
||||
// Contrat :
|
||||
// searchCity(postalCode) -> liste { city, postalCode }
|
||||
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
|
||||
// En cas d'erreur/timeout, la méthode THROW une
|
||||
// AddressAutocompleteUnavailableError. Le composant consommateur catch,
|
||||
// avertit l'utilisateur et bascule en saisie libre.
|
||||
|
||||
/** URL de l'endpoint de recherche BAN. */
|
||||
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
|
||||
|
||||
/** Une suggestion de ville renvoyée à partir d'un code postal. */
|
||||
export interface CitySuggestion {
|
||||
city: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
/** Une suggestion d'adresse complète (saisie assistée du champ « Rue »). */
|
||||
export interface AddressSuggestion {
|
||||
label: string
|
||||
street: string
|
||||
postalCode: string
|
||||
city: string
|
||||
}
|
||||
|
||||
export interface AddressAutocomplete {
|
||||
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
||||
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||
}
|
||||
|
||||
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
|
||||
export class AddressAutocompleteUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Address autocomplete (BAN) is not available.')
|
||||
this.name = 'AddressAutocompleteUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Propriétés d'une « feature » GeoJSON renvoyée par la BAN (champs utilisés). */
|
||||
interface BanFeatureProperties {
|
||||
label?: string
|
||||
name?: string
|
||||
street?: string
|
||||
postcode?: string
|
||||
city?: string
|
||||
}
|
||||
|
||||
/** Réponse GeoJSON FeatureCollection de la BAN. */
|
||||
interface BanResponse {
|
||||
features?: { properties?: BanFeatureProperties }[]
|
||||
}
|
||||
|
||||
export function useAddressAutocomplete(): AddressAutocomplete {
|
||||
return {
|
||||
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
|
||||
let res: BanResponse
|
||||
try {
|
||||
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
|
||||
query: { q: postalCode, type: 'municipality' },
|
||||
})
|
||||
}
|
||||
catch {
|
||||
throw new AddressAutocompleteUnavailableError()
|
||||
}
|
||||
|
||||
return (res.features ?? []).map((feature) => {
|
||||
const props = feature.properties ?? {}
|
||||
return {
|
||||
city: props.city ?? props.name ?? '',
|
||||
postalCode: props.postcode ?? '',
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
|
||||
// Pas de `type=housenumber` ici : sans filtre, la BAN classe rues +
|
||||
// numéros par pertinence (comportement d'autocomplétion attendu).
|
||||
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
|
||||
const banQuery: Record<string, string> = { q: query }
|
||||
if (postalCode) {
|
||||
banQuery.postcode = postalCode
|
||||
}
|
||||
|
||||
let res: BanResponse
|
||||
try {
|
||||
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
|
||||
}
|
||||
catch {
|
||||
throw new AddressAutocompleteUnavailableError()
|
||||
}
|
||||
|
||||
return (res.features ?? []).map((feature) => {
|
||||
const props = feature.properties ?? {}
|
||||
return {
|
||||
label: props.label ?? '',
|
||||
// `name` porte la ligne d'adresse complète (numéro + voie) ;
|
||||
// `street` ne contient que la voie. On privilégie `name`.
|
||||
street: props.name ?? props.street ?? '',
|
||||
postalCode: props.postcode ?? '',
|
||||
city: props.city ?? '',
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,13 @@ import type { Address } from '~/modules/directory/services/dto/address'
|
||||
import { useContactService } from '~/modules/directory/services/contacts'
|
||||
import { useAddressService } from '~/modules/directory/services/addresses'
|
||||
|
||||
type Owner = { client?: string, prospect?: string, prestataire?: string }
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
/**
|
||||
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
|
||||
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
|
||||
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
|
||||
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur.
|
||||
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
|
||||
* tel quel par les deux pages.
|
||||
* Logique partagée des fiches détail Client/Prospect : gestion des blocs
|
||||
* répétables Contact et Adresse (chargement, ajout, édition par bloc avec
|
||||
* persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
|
||||
* (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
|
||||
*/
|
||||
export function useDirectoryDetail(owner: Owner) {
|
||||
const contactService = useContactService()
|
||||
@@ -19,8 +17,6 @@ export function useDirectoryDetail(owner: Owner) {
|
||||
|
||||
const contacts = ref<Contact[]>([])
|
||||
const addresses = ref<Address[]>([])
|
||||
const savingContacts = ref(false)
|
||||
const savingAddresses = ref(false)
|
||||
|
||||
function emptyContact(): Contact {
|
||||
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
|
||||
@@ -29,75 +25,54 @@ export function useDirectoryDetail(owner: Owner) {
|
||||
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
|
||||
}
|
||||
|
||||
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
|
||||
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
|
||||
function onContactInput(index: number, value: Contact): void {
|
||||
async function onContactInput(index: number, value: Contact): Promise<void> {
|
||||
contacts.value[index] = value
|
||||
await persistContact(index)
|
||||
}
|
||||
function onAddressInput(index: number, value: Address): void {
|
||||
addresses.value[index] = value
|
||||
async function persistContact(index: number): Promise<void> {
|
||||
const c = contacts.value[index]
|
||||
if (!c) return
|
||||
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
|
||||
if (c.id && c.id > 0) {
|
||||
await contactService.update(c.id, payload)
|
||||
} else if (c.lastName || c.firstName) {
|
||||
const created = await contactService.create(payload)
|
||||
contacts.value[index] = created
|
||||
}
|
||||
}
|
||||
|
||||
function addContact(): void {
|
||||
contacts.value.push(emptyContact())
|
||||
}
|
||||
function addAddress(): void {
|
||||
addresses.value.push(emptyAddress())
|
||||
}
|
||||
|
||||
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
|
||||
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
|
||||
// simplement retirée de la liste.
|
||||
async function removeContact(index: number): Promise<void> {
|
||||
const c = contacts.value[index]
|
||||
if (c?.id && c.id > 0) await contactService.remove(c.id)
|
||||
contacts.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function onAddressInput(index: number, value: Address): Promise<void> {
|
||||
addresses.value[index] = value
|
||||
await persistAddress(index)
|
||||
}
|
||||
async function persistAddress(index: number): Promise<void> {
|
||||
const a = addresses.value[index]
|
||||
if (!a) return
|
||||
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
|
||||
if (a.id && a.id > 0) {
|
||||
await addressService.update(a.id, payload)
|
||||
} else if (a.street || a.city || a.postalCode) {
|
||||
const created = await addressService.create(payload)
|
||||
addresses.value[index] = created
|
||||
}
|
||||
}
|
||||
function addAddress(): void {
|
||||
addresses.value.push(emptyAddress())
|
||||
}
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
const a = addresses.value[index]
|
||||
if (a?.id && a.id > 0) await addressService.remove(a.id)
|
||||
addresses.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
|
||||
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
|
||||
async function saveContacts(): Promise<void> {
|
||||
if (savingContacts.value) return
|
||||
savingContacts.value = true
|
||||
try {
|
||||
for (let i = 0; i < contacts.value.length; i++) {
|
||||
const c = contacts.value[i]
|
||||
if (!c) continue
|
||||
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
|
||||
if (c.id && c.id > 0) {
|
||||
contacts.value[i] = await contactService.update(c.id, payload)
|
||||
} else if (c.lastName || c.firstName) {
|
||||
contacts.value[i] = await contactService.create(payload)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
savingContacts.value = false
|
||||
}
|
||||
}
|
||||
async function saveAddresses(): Promise<void> {
|
||||
if (savingAddresses.value) return
|
||||
savingAddresses.value = true
|
||||
try {
|
||||
for (let i = 0; i < addresses.value.length; i++) {
|
||||
const a = addresses.value[i]
|
||||
if (!a) continue
|
||||
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
|
||||
if (a.id && a.id > 0) {
|
||||
addresses.value[i] = await addressService.update(a.id, payload)
|
||||
} else if (a.street || a.city || a.postalCode) {
|
||||
addresses.value[i] = await addressService.create(payload)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
savingAddresses.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
contacts.value = await contactService.getByOwner(owner)
|
||||
addresses.value = await addressService.getByOwner(owner)
|
||||
@@ -106,16 +81,12 @@ export function useDirectoryDetail(owner: Owner) {
|
||||
return {
|
||||
contacts,
|
||||
addresses,
|
||||
savingContacts,
|
||||
savingAddresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
saveContacts,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
saveAddresses,
|
||||
load,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
<span class="inline-flex items-center gap-3">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
{{ client?.name ?? '…' }}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||
<template v-else-if="client">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<template #info>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
||||
<MalioInputText
|
||||
v-model="info.name"
|
||||
class="col-span-2"
|
||||
:label="$t('directory.info.fields.name')"
|
||||
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||
@blur="infoTouched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.email"
|
||||
:label="$t('directory.info.fields.email')"
|
||||
:error="emailError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.phone"
|
||||
:label="$t('directory.info.fields.phone')"
|
||||
:error="phoneError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.website"
|
||||
class="col-span-2"
|
||||
:label="$t('directory.info.fields.website')"
|
||||
:error="websiteError"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center pt-2">
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingInfo || !infoValid"
|
||||
@click="saveInfo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contact>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryContactBlock
|
||||
@@ -60,22 +19,13 @@
|
||||
@update:model-value="(v) => onContactInput(i, v)"
|
||||
@remove="removeContact(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingContacts"
|
||||
@click="saveContacts"
|
||||
/>
|
||||
</div>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -90,38 +40,27 @@
|
||||
@update:model-value="(v) => onAddressInput(i, v)"
|
||||
@remove="removeAddress(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingAddresses"
|
||||
@click="saveAddresses"
|
||||
/>
|
||||
</div>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #report>
|
||||
<CommercialReportTab :owner="owner" :can-manage="canManage" />
|
||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
@@ -137,70 +76,31 @@ const clientService = useClientService()
|
||||
const {
|
||||
contacts,
|
||||
addresses,
|
||||
savingContacts,
|
||||
savingAddresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
saveContacts,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
saveAddresses,
|
||||
load,
|
||||
} = useDirectoryDetail(owner)
|
||||
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('directory.clients.manage'))
|
||||
|
||||
const client = ref<Client | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const activeTab = ref('contact')
|
||||
const tabs = [
|
||||
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
|
||||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||||
]
|
||||
|
||||
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
|
||||
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
|
||||
const info = reactive({ name: '', email: '', phone: '', website: '' })
|
||||
const infoTouched = reactive({ name: false })
|
||||
const savingInfo = ref(false)
|
||||
|
||||
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
|
||||
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
|
||||
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
|
||||
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
|
||||
|
||||
async function saveInfo(): Promise<void> {
|
||||
infoTouched.name = true
|
||||
if (!info.name.trim() || !infoValid.value || savingInfo.value) return
|
||||
savingInfo.value = true
|
||||
try {
|
||||
client.value = await clientService.update(id, {
|
||||
name: info.name.trim(),
|
||||
email: info.email.trim() || null,
|
||||
phone: info.phone.trim() || null,
|
||||
website: info.website.trim() || null,
|
||||
})
|
||||
} finally {
|
||||
savingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/directory')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
client.value = await clientService.getById(id)
|
||||
info.name = client.value.name ?? ''
|
||||
info.email = client.value.email ?? ''
|
||||
info.phone = client.value.phone ?? ''
|
||||
info.website = client.value.website ?? ''
|
||||
await load()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t('directory.title') }}
|
||||
</PageHeader>
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<!-- Clients -->
|
||||
<template #clients>
|
||||
@@ -32,17 +31,6 @@
|
||||
<template #cell-phone="{ item }">
|
||||
{{ (item as Client).phone ?? '—' }}
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end" @click.stop>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:trash-can-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="askDeleteClient(item as Client)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -87,63 +75,20 @@
|
||||
{{ (item as ProspectRow).phone ?? '—' }}
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end gap-2" @click.stop>
|
||||
<div
|
||||
v-if="!(item as ProspectRow).convertedClient"
|
||||
class="flex justify-end"
|
||||
@click.stop
|
||||
>
|
||||
<MalioButtonIcon
|
||||
v-if="!(item as ProspectRow).convertedClient"
|
||||
icon="mdi:account-convert"
|
||||
:aria-label="$t('prospects.convert')"
|
||||
button-class="!bg-green-100 !text-green-700"
|
||||
:icon-size="18"
|
||||
@click="convertProspect(item as ProspectRow)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:trash-can-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="askDeleteProspect(item as ProspectRow)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Prestataires -->
|
||||
<template #prestataires>
|
||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||
<div class="flex items-center justify-end">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.prestataires.add')"
|
||||
@click="openCreatePrestataire"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="prestataireColumns"
|
||||
:items="prestataires"
|
||||
:total-items="prestataires.length"
|
||||
:empty-message="$t('directory.prestataires.empty')"
|
||||
@row-click="openEditPrestataire"
|
||||
>
|
||||
<template #cell-email="{ item }">
|
||||
{{ (item as Prestataire).email ?? '—' }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ (item as Prestataire).phone ?? '—' }}
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end" @click.stop>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:trash-can-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="askDeletePrestataire(item as Prestataire)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-neutral-300">—</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
@@ -160,19 +105,6 @@
|
||||
:prospect="selectedProspect"
|
||||
@saved="onProspectSaved"
|
||||
/>
|
||||
<PrestataireDrawer
|
||||
v-model="prestataireDrawerOpen"
|
||||
:prestataire="selectedPrestataire"
|
||||
@saved="loadPrestataires"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
v-model="deleteModalOpen"
|
||||
:title="deleteModalTitle"
|
||||
:message="deleteModalMessage"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -181,8 +113,6 @@ import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
|
||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
@@ -193,13 +123,11 @@ useHead({ title: t('directory.title') })
|
||||
|
||||
const clientService = useClientService()
|
||||
const prospectService = useProspectService()
|
||||
const prestataireService = usePrestataireService()
|
||||
|
||||
const activeTab = ref('clients')
|
||||
const tabs = [
|
||||
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
|
||||
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
|
||||
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
|
||||
]
|
||||
|
||||
// --- Clients ---
|
||||
@@ -208,10 +136,9 @@ const clientDrawerOpen = ref(false)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
|
||||
const clientColumns = [
|
||||
{ key: 'name', label: t('prospects.fields.company') },
|
||||
{ key: 'name', label: t('prospects.fields.name') },
|
||||
{ key: 'email', label: t('prospects.fields.email') },
|
||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||
{ key: 'actions', label: '' },
|
||||
]
|
||||
|
||||
async function loadClients() {
|
||||
@@ -242,6 +169,7 @@ const statusOptions = [
|
||||
]
|
||||
|
||||
const prospectColumns = [
|
||||
{ key: 'name', label: t('prospects.fields.name') },
|
||||
{ key: 'company', label: t('prospects.fields.company') },
|
||||
{ key: 'status', label: t('prospects.fields.status') },
|
||||
{ key: 'email', label: t('prospects.fields.email') },
|
||||
@@ -297,102 +225,10 @@ async function onProspectSaved() {
|
||||
await Promise.all([loadProspects(), loadClients()])
|
||||
}
|
||||
|
||||
// --- Prestataires ---
|
||||
const prestataires = ref<Prestataire[]>([])
|
||||
const prestataireDrawerOpen = ref(false)
|
||||
const selectedPrestataire = ref<Prestataire | null>(null)
|
||||
|
||||
const prestataireColumns = [
|
||||
{ key: 'name', label: t('prospects.fields.company') },
|
||||
{ key: 'email', label: t('prospects.fields.email') },
|
||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||
{ key: 'actions', label: '' },
|
||||
]
|
||||
|
||||
async function loadPrestataires() {
|
||||
prestataires.value = await prestataireService.getAll()
|
||||
}
|
||||
|
||||
function openCreatePrestataire() {
|
||||
selectedPrestataire.value = null
|
||||
prestataireDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditPrestataire(item: Record<string, unknown>) {
|
||||
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
|
||||
}
|
||||
|
||||
// --- Suppression (clients, prospects & prestataires) ---
|
||||
type DeleteTarget =
|
||||
| { type: 'client'; item: Client }
|
||||
| { type: 'prospect'; item: Prospect }
|
||||
| { type: 'prestataire'; item: Prestataire }
|
||||
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTarget = ref<DeleteTarget | null>(null)
|
||||
|
||||
const deleteModalTitle = computed(() => {
|
||||
switch (deleteTarget.value?.type) {
|
||||
case 'prospect':
|
||||
return t('prospects.deleteConfirmTitle')
|
||||
case 'prestataire':
|
||||
return t('prestataires.deleteConfirmTitle')
|
||||
default:
|
||||
return t('clients.deleteConfirmTitle')
|
||||
}
|
||||
})
|
||||
|
||||
const deleteModalMessage = computed(() => {
|
||||
const target = deleteTarget.value
|
||||
if (!target) return ''
|
||||
switch (target.type) {
|
||||
case 'prospect':
|
||||
return t('prospects.deleteConfirmMessage', { name: target.item.company })
|
||||
case 'prestataire':
|
||||
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
|
||||
default:
|
||||
return t('clients.deleteConfirmMessage', { name: target.item.name })
|
||||
}
|
||||
})
|
||||
|
||||
function askDeleteClient(item: Client) {
|
||||
deleteTarget.value = { type: 'client', item }
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function askDeleteProspect(item: Prospect) {
|
||||
deleteTarget.value = { type: 'prospect', item }
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
function askDeletePrestataire(item: Prestataire) {
|
||||
deleteTarget.value = { type: 'prestataire', item }
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const target = deleteTarget.value
|
||||
if (!target) return
|
||||
|
||||
if (target.type === 'client') {
|
||||
await clientService.remove(target.item.id)
|
||||
await loadClients()
|
||||
} else if (target.type === 'prospect') {
|
||||
await prospectService.remove(target.item.id)
|
||||
await loadProspects()
|
||||
} else {
|
||||
await prestataireService.remove(target.item.id)
|
||||
await loadPrestataires()
|
||||
}
|
||||
|
||||
deleteModalOpen.value = false
|
||||
deleteTarget.value = null
|
||||
}
|
||||
|
||||
watch(statusFilter, loadProspects)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
|
||||
await Promise.all([loadClients(), loadProspects()])
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
<span class="inline-flex items-center gap-3">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
{{ prestataire?.name ?? '…' }}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||
<template v-else-if="prestataire">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<template #info>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
||||
<MalioInputText
|
||||
v-model="info.name"
|
||||
class="col-span-2"
|
||||
:label="$t('directory.info.fields.name')"
|
||||
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||
@blur="infoTouched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.email"
|
||||
:label="$t('directory.info.fields.email')"
|
||||
:error="emailError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.phone"
|
||||
:label="$t('directory.info.fields.phone')"
|
||||
:error="phoneError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.website"
|
||||
class="col-span-2"
|
||||
:label="$t('directory.info.fields.website')"
|
||||
:error="websiteError"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center pt-2">
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingInfo || !infoValid"
|
||||
@click="saveInfo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contact>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryContactBlock
|
||||
v-for="(contact, i) in contacts"
|
||||
:key="contact.id || `new-${i}`"
|
||||
:model-value="contact"
|
||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||
:removable="contacts.length > 0"
|
||||
@update:model-value="(v) => onContactInput(i, v)"
|
||||
@remove="removeContact(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingContacts"
|
||||
@click="saveContacts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #address>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryAddressBlock
|
||||
v-for="(address, i) in addresses"
|
||||
:key="address.id || `new-${i}`"
|
||||
:model-value="address"
|
||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||
:removable="addresses.length > 0"
|
||||
@update:model-value="(v) => onAddressInput(i, v)"
|
||||
@remove="removeAddress(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingAddresses"
|
||||
@click="saveAddresses"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #report>
|
||||
<CommercialReportTab :owner="owner" :can-manage="canManage" />
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
|
||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const id = Number(route.params.id)
|
||||
const ownerIri = `/api/prestataires/${id}`
|
||||
const owner = { prestataire: ownerIri }
|
||||
|
||||
const prestataireService = usePrestataireService()
|
||||
const {
|
||||
contacts,
|
||||
addresses,
|
||||
savingContacts,
|
||||
savingAddresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
saveContacts,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
saveAddresses,
|
||||
load,
|
||||
} = useDirectoryDetail(owner)
|
||||
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('directory.providers.manage'))
|
||||
|
||||
const prestataire = ref<Prestataire | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const tabs = [
|
||||
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
|
||||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||||
]
|
||||
|
||||
const info = reactive({ name: '', email: '', phone: '', website: '' })
|
||||
const infoTouched = reactive({ name: false })
|
||||
const savingInfo = ref(false)
|
||||
|
||||
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
|
||||
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
|
||||
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
|
||||
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
|
||||
|
||||
async function saveInfo(): Promise<void> {
|
||||
infoTouched.name = true
|
||||
if (!info.name.trim() || !infoValid.value || savingInfo.value) return
|
||||
savingInfo.value = true
|
||||
try {
|
||||
prestataire.value = await prestataireService.update(id, {
|
||||
name: info.name.trim(),
|
||||
email: info.email.trim() || null,
|
||||
phone: info.phone.trim() || null,
|
||||
website: info.website.trim() || null,
|
||||
})
|
||||
} finally {
|
||||
savingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/directory')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
prestataire.value = await prestataireService.getById(id)
|
||||
info.name = prestataire.value.name ?? ''
|
||||
info.email = prestataire.value.email ?? ''
|
||||
info.phone = prestataire.value.phone ?? ''
|
||||
info.website = prestataire.value.website ?? ''
|
||||
await load()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -1,69 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
<span class="inline-flex items-center gap-3">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
{{ prospect?.company ?? '…' }}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||
<template v-else-if="prospect">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<template #info>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
||||
<MalioInputText
|
||||
v-model="info.company"
|
||||
class="col-span-2"
|
||||
:label="$t('prospects.fields.company')"
|
||||
:error="infoTouched.company && !info.company.trim() ? $t('prospects.validation.companyRequired') : ''"
|
||||
@blur="infoTouched.company = true"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="info.status"
|
||||
:label="$t('prospects.fields.status')"
|
||||
:options="statusOptions"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.website"
|
||||
:label="$t('prospects.fields.website')"
|
||||
:error="websiteError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.email"
|
||||
:label="$t('prospects.fields.email')"
|
||||
:error="emailError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.phone"
|
||||
:label="$t('prospects.fields.phone')"
|
||||
:error="phoneError"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="info.source"
|
||||
class="col-span-2"
|
||||
:label="$t('prospects.fields.source')"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="info.notes"
|
||||
class="col-span-2"
|
||||
:label="$t('prospects.fields.notes')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center pt-2">
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingInfo || !infoValid"
|
||||
@click="saveInfo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #contact>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryContactBlock
|
||||
@@ -75,22 +19,13 @@
|
||||
@update:model-value="(v) => onContactInput(i, v)"
|
||||
@remove="removeContact(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingContacts"
|
||||
@click="saveContacts"
|
||||
/>
|
||||
</div>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -105,38 +40,27 @@
|
||||
@update:model-value="(v) => onAddressInput(i, v)"
|
||||
@remove="removeAddress(i)"
|
||||
/>
|
||||
<div class="flex justify-center gap-3 pt-2">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
<MalioButton
|
||||
button-class="w-auto px-6"
|
||||
:label="$t('common.save')"
|
||||
:disabled="savingAddresses"
|
||||
@click="saveAddresses"
|
||||
/>
|
||||
</div>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #report>
|
||||
<CommercialReportTab :owner="owner" :can-manage="canManage" />
|
||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
|
||||
import type { Prospect } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
@@ -152,92 +76,31 @@ const prospectService = useProspectService()
|
||||
const {
|
||||
contacts,
|
||||
addresses,
|
||||
savingContacts,
|
||||
savingAddresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
saveContacts,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
saveAddresses,
|
||||
load,
|
||||
} = useDirectoryDetail(owner)
|
||||
|
||||
const { can } = usePermissions()
|
||||
const canManage = computed(() => can('directory.prospects.manage'))
|
||||
|
||||
const prospect = ref<Prospect | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const activeTab = ref('contact')
|
||||
const tabs = [
|
||||
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
|
||||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('prospects.status.new'), value: 'new' },
|
||||
{ label: t('prospects.status.contacted'), value: 'contacted' },
|
||||
{ label: t('prospects.status.qualified'), value: 'qualified' },
|
||||
{ label: t('prospects.status.won'), value: 'won' },
|
||||
{ label: t('prospects.status.lost'), value: 'lost' },
|
||||
]
|
||||
|
||||
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
|
||||
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
|
||||
const info = reactive<{
|
||||
company: string
|
||||
email: string
|
||||
phone: string
|
||||
website: string
|
||||
status: ProspectStatus
|
||||
source: string
|
||||
notes: string
|
||||
}>({ company: '', email: '', phone: '', website: '', status: 'new', source: '', notes: '' })
|
||||
const infoTouched = reactive({ company: false })
|
||||
const savingInfo = ref(false)
|
||||
|
||||
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
|
||||
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
|
||||
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
|
||||
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
|
||||
|
||||
async function saveInfo(): Promise<void> {
|
||||
infoTouched.company = true
|
||||
if (!info.company.trim() || !infoValid.value || savingInfo.value) return
|
||||
savingInfo.value = true
|
||||
try {
|
||||
prospect.value = await prospectService.update(id, {
|
||||
company: info.company.trim(),
|
||||
email: info.email.trim() || null,
|
||||
phone: info.phone.trim() || null,
|
||||
website: info.website.trim() || null,
|
||||
status: info.status,
|
||||
source: info.source.trim() || null,
|
||||
notes: info.notes.trim() || null,
|
||||
})
|
||||
} finally {
|
||||
savingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/directory')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
prospect.value = await prospectService.getById(id)
|
||||
info.company = prospect.value.company ?? ''
|
||||
info.email = prospect.value.email ?? ''
|
||||
info.phone = prospect.value.phone ?? ''
|
||||
info.website = prospect.value.website ?? ''
|
||||
info.status = prospect.value.status ?? 'new'
|
||||
info.source = prospect.value.source ?? ''
|
||||
info.notes = prospect.value.notes ?? ''
|
||||
await load()
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Address, AddressWrite } from './dto/address'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string, prestataire?: string }
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useAddressService() {
|
||||
const api = useApi()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CommercialReport, CommercialReportWrite } from './dto/commercial-r
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string, prestataire?: string }
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useCommercialReportService() {
|
||||
const api = useApi()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Contact, ContactWrite } from './dto/contact'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string, prestataire?: string }
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useContactService() {
|
||||
const api = useApi()
|
||||
|
||||
@@ -9,7 +9,6 @@ export type Address = {
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
}
|
||||
|
||||
export type AddressWrite = {
|
||||
@@ -21,5 +20,4 @@ export type AddressWrite = {
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ export type Client = {
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
website: string | null
|
||||
}
|
||||
|
||||
export type ClientWrite = {
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
website?: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export type CommercialReport = {
|
||||
author?: { id: number, username: string } | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
documents?: ReportDocument[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
@@ -25,5 +24,4 @@ export type CommercialReportWrite = {
|
||||
type: ReportType
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export type Contact = {
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
}
|
||||
|
||||
export type ContactWrite = {
|
||||
@@ -21,5 +20,4 @@ export type ContactWrite = {
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
prestataire?: string | null
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type Prestataire = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
website: string | null
|
||||
}
|
||||
|
||||
export type PrestataireWrite = {
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
website?: string | null
|
||||
}
|
||||
@@ -5,10 +5,10 @@ export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
|
||||
export type Prospect = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
company: string
|
||||
name: string
|
||||
company: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
website: string | null
|
||||
status: ProspectStatus
|
||||
source: string | null
|
||||
notes: string | null
|
||||
@@ -18,11 +18,11 @@ export type Prospect = {
|
||||
}
|
||||
|
||||
export type ProspectWrite = {
|
||||
company: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
website?: string | null
|
||||
status?: ProspectStatus
|
||||
source?: string | null
|
||||
notes?: string | null
|
||||
name: string
|
||||
company: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
status: ProspectStatus
|
||||
source: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Prestataire, PrestataireWrite } from './dto/prestataire'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function usePrestataireService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Prestataire[]> {
|
||||
const data = await api.get<HydraCollection<Prestataire>>('/prestataires')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Prestataire> {
|
||||
return api.get<Prestataire>(`/prestataires/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: PrestataireWrite): Promise<Prestataire> {
|
||||
return api.post<Prestataire>('/prestataires', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prestataires.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<PrestataireWrite>): Promise<Prestataire> {
|
||||
return api.patch<Prestataire>(`/prestataires/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prestataires.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/prestataires/${id}`, {}, {
|
||||
toastSuccessKey: 'prestataires.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, remove }
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Validateurs partagés du répertoire (annuaire). Chaque validateur considère
|
||||
// une valeur VIDE comme valide : les champs email/téléphone/site web sont
|
||||
// facultatifs — la validation ne porte que sur le format quand c'est renseigné.
|
||||
|
||||
/** Email basique (présence d'un @ entouré de caractères, un point dans le domaine). */
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
* Téléphone français : 10 chiffres commençant par 0 (ex. `0549200910`) — format
|
||||
* saisi par l'utilisateur, sans séparateurs — ou notation internationale
|
||||
* `+33XXXXXXXXX` (9 chiffres après l'indicatif). Les espaces, points et tirets
|
||||
* sont tolérés à la frappe (retirés avant contrôle).
|
||||
*/
|
||||
const FR_PHONE_NATIONAL_RE = /^0\d{9}$/
|
||||
const FR_PHONE_INTL_RE = /^\+33\d{9}$/
|
||||
|
||||
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]+$/
|
||||
|
||||
/** Retire les séparateurs usuels d'un numéro (espaces, points, tirets, parenthèses). */
|
||||
export function stripPhoneSeparators(value: string): string {
|
||||
return value.replace(/[\s.\-()]/g, '')
|
||||
}
|
||||
|
||||
export function isValidEmail(value: string | null | undefined): boolean {
|
||||
const v = (value ?? '').trim()
|
||||
if (v === '') return true
|
||||
return EMAIL_RE.test(v)
|
||||
}
|
||||
|
||||
export function isValidFrPhone(value: string | null | undefined): boolean {
|
||||
const v = stripPhoneSeparators((value ?? '').trim())
|
||||
if (v === '') return true
|
||||
return FR_PHONE_NATIONAL_RE.test(v) || FR_PHONE_INTL_RE.test(v)
|
||||
}
|
||||
|
||||
export function isValidUrl(value: string | null | undefined): boolean {
|
||||
const v = (value ?? '').trim()
|
||||
if (v === '') return true
|
||||
return URL_RE.test(v)
|
||||
}
|
||||
@@ -95,13 +95,11 @@ function handleTaskLinked(_taskId: number): void {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex-shrink-0">
|
||||
<PageHeader>
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<h1 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('mail.title') }}
|
||||
<template #actions>
|
||||
<MailRefreshButton />
|
||||
</template>
|
||||
</PageHeader>
|
||||
</h1>
|
||||
<MailRefreshButton />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
@@ -355,9 +355,9 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<!-- Header + Filters -->
|
||||
<PageHeader>
|
||||
{{ $t('myTasks.title') }}
|
||||
<template #actions>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
@@ -378,79 +378,78 @@ onMounted(async () => {
|
||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="sortById"
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban View — grouped by canonical category -->
|
||||
<div v-if="viewMode === 'kanban'">
|
||||
<div class="flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
@@ -510,7 +509,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-if="viewMode === 'list'" class="flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||
<TaskBulkActions
|
||||
:selected-count="selectedTaskIds.size"
|
||||
:total-count="tasks.length"
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ project?.name ?? '' }} — {{ $t('archive.title') }}
|
||||
<template #subheader>
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>{{ project?.name ?? '' }} — Groupes</PageHeader>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProjectGroupTab :project-id="projectId" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<PageHeader>
|
||||
{{ project?.name ?? '' }}
|
||||
<template #actions>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
@@ -30,67 +30,66 @@
|
||||
@click="projectDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedGroupId"
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedAssigneeId"
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="viewMode === 'list'"
|
||||
v-model="selectedStatusId"
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedPriorityId"
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedEffortId"
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ $t('projects.title') }}
|
||||
<template #actions>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
@@ -23,8 +23,8 @@
|
||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ $t('reporting.title') }}
|
||||
<template #subheader>
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('reporting.filters.period')"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="w-40">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('reporting.filters.from') }}
|
||||
</label>
|
||||
<MalioDate
|
||||
v-model="customFrom"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('reporting.filters.to') }}
|
||||
</label>
|
||||
<MalioDate
|
||||
v-model="customTo"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('reporting.filters.project')"
|
||||
:empty-option-label="$t('reporting.filters.allProjects')"
|
||||
group-class="!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('reporting.filters.user')"
|
||||
:empty-option-label="$t('reporting.filters.allUsers')"
|
||||
group-class="!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
{{ $t('reporting.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap items-end gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('reporting.filters.period')"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div class="w-40">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('reporting.filters.from') }}
|
||||
</label>
|
||||
<MalioDate
|
||||
v-model="customFrom"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="w-40">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('reporting.filters.to') }}
|
||||
</label>
|
||||
<MalioDate
|
||||
v-model="customTo"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('reporting.filters.project')"
|
||||
:empty-option-label="$t('reporting.filters.allProjects')"
|
||||
group-class="!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('reporting.filters.user')"
|
||||
:empty-option-label="$t('reporting.filters.allUsers')"
|
||||
group-class="!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||
|
||||
@@ -1,104 +1,101 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div ref="pageHeaderEl" class="flex-shrink-0">
|
||||
<PageHeader>
|
||||
Suivi des temps
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="shrink-0"
|
||||
@click="openCreateDrawer()"
|
||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="shrink-0"
|
||||
@click="openCreateDrawer()"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||
<span class="sm:hidden">Activité</span>
|
||||
</MalioButton>
|
||||
</div>
|
||||
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
@click="navigatePrev"
|
||||
/>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
@click="navigateNext"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||
<span class="sm:hidden">Activité</span>
|
||||
</MalioButton>
|
||||
</template>
|
||||
<template #subheader>
|
||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-left"
|
||||
aria-label="Précédent"
|
||||
variant="ghost"
|
||||
@click="navigatePrev"
|
||||
/>
|
||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||
{{ currentMonthLabel }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:chevron-right"
|
||||
aria-label="Suivant"
|
||||
variant="ghost"
|
||||
@click="navigateNext"
|
||||
/>
|
||||
</div>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||
<button
|
||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||
:key="mode"
|
||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||
:class="viewMode === mode
|
||||
? 'bg-primary-500 text-white shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="viewMode = mode"
|
||||
>
|
||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
empty-option-label="Tous"
|
||||
/>
|
||||
</div>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="[&>div]:!mt-0">
|
||||
<MalioSelect
|
||||
v-model="selectedTagId"
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
@click="exportDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<MalioButton
|
||||
:label="$t('timeEntries.export')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:download"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
@click="exportDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-0 -mb-24 min-h-0 flex-1">
|
||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
||||
<TimeEntryList
|
||||
v-if="viewMode === 'list'"
|
||||
:entries="filteredEntries"
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.16",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2220,9 +2220,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.16",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.16/layer-ui-1.7.16.tgz",
|
||||
"integrity": "sha512-24scQzhfnwLJr+JTlusiiazkjEK8pqwPp5NZGLdFbP32f+J9RpwoJf/U0ztwIJssXEeYvJB4cdLDYow7dZJv6Q==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.5/layer-ui-1.7.5.tgz",
|
||||
"integrity": "sha512-xryrAYgVgX3eurEWXT/d0p4r/MBYNBB3mBnvV6xVcFhzxW+HfOra8hsVHLvrCtd+m5E1t7PDRzjw1FObkV6fdQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.16",
|
||||
"@malio/layer-ui": "^1.7.5",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
+22
-21
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
Administration
|
||||
<template #subheader>
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
|
||||
|
||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||
<nav class="flex gap-4 sm:gap-6">
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
@@ -50,6 +50,7 @@ const canViewRoles = computed(() => can('core.roles.view'))
|
||||
const canViewAudit = computed(() => can('core.audit_log.view'))
|
||||
|
||||
const tabs = [
|
||||
{ key: 'clients', label: 'Clients' },
|
||||
{ key: 'workflows', label: 'Workflows' },
|
||||
{ key: 'efforts', label: 'Efforts' },
|
||||
{ key: 'priorities', label: 'Priorités' },
|
||||
@@ -71,5 +72,5 @@ const visibleTabs = computed(() =>
|
||||
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
|
||||
)
|
||||
|
||||
const activeTab = ref<TabKey>('workflows')
|
||||
const activeTab = ref<TabKey>('clients')
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>{{ $t('sharedFiles.title') }}</PageHeader>
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>
|
||||
|
||||
<!-- Fil d'Ariane -->
|
||||
<nav class="flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||
<nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||
<span>/</span>
|
||||
@@ -12,7 +12,7 @@
|
||||
</nav>
|
||||
|
||||
<!-- Filtre local + rechargement -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<div class="max-w-sm flex-1">
|
||||
<MalioInputText
|
||||
v-model="filter"
|
||||
|
||||
+34
-35
@@ -506,40 +506,39 @@ const lineOptions = {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ $t('dashboard.title') }}
|
||||
<template #subheader>
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||
@@ -548,7 +547,7 @@ const lineOptions = {
|
||||
|
||||
<template v-else>
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||
{{ $t('dashboard.stats.hoursPeriod') }}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,5 +1,6 @@
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const sidebarCollapsed = ref(false)
|
||||
const sidebarOpen = ref(false)
|
||||
const darkMode = ref(false)
|
||||
|
||||
if (import.meta.client) {
|
||||
@@ -44,5 +45,13 @@ export const useUiStore = defineStore('ui', () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, darkMode, toggleSidebar, toggleDarkMode }
|
||||
function openMobileSidebar() {
|
||||
sidebarOpen.value = true
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
|
||||
})
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
/**
|
||||
* Appel HTTP vers un service EXTERNE (hors API Lesstime) : pas de cookie de
|
||||
* session, pas d'enveloppe Hydra, timeout court. Utilisé par l'autocomplétion
|
||||
* d'adresse branchée sur la Base Adresse Nationale (api-adresse.data.gouv.fr).
|
||||
* Ne jamais passer par `useApi()` pour ces domaines tiers.
|
||||
*/
|
||||
export interface HttpExternalOptions {
|
||||
/** Paramètres de query string (encodés par ofetch). */
|
||||
query?: Record<string, string | number | undefined>
|
||||
/** Timeout en millisecondes avant abandon (défaut 5000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export async function httpExternal<T>(
|
||||
url: string,
|
||||
opts: HttpExternalOptions = {},
|
||||
): Promise<T> {
|
||||
return $fetch<T>(url, {
|
||||
query: opts.query,
|
||||
credentials: 'omit',
|
||||
retry: 0,
|
||||
timeout: opts.timeoutMs ?? 5000,
|
||||
})
|
||||
}
|
||||
@@ -27,9 +27,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
|
||||
echo "==> Running migrations..."
|
||||
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
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260624153709 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Directory: prestataire entity + website on client/prospect/prestataire + prestataire ownership on contacts/addresses/reports + prospect company-only';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_60A26480DE12AB56 ON prestataire (created_by)');
|
||||
$this->addSql('CREATE INDEX IDX_60A2648016FE72E1 ON prestataire (updated_by)');
|
||||
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A26480DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A2648016FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE client ADD website VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE commercial_report ADD prestataire_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT FK_886919D8BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_886919D8BE3DB2B7 ON commercial_report (prestataire_id)');
|
||||
$this->addSql('ALTER TABLE directory_address ADD prestataire_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT FK_6E5D9707BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_6E5D9707BE3DB2B7 ON directory_address (prestataire_id)');
|
||||
$this->addSql('ALTER TABLE directory_contact ADD prestataire_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT FK_2F711EBEBE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_2F711EBEBE3DB2B7 ON directory_contact (prestataire_id)');
|
||||
|
||||
// Prospect désormais société-only : on conserve la donnée existante en
|
||||
// recopiant le nom dans la société quand celle-ci est vide, avant de
|
||||
// rendre la colonne obligatoire et de supprimer la colonne name.
|
||||
$this->addSql('ALTER TABLE prospect ADD website VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql("UPDATE prospect SET company = name WHERE company IS NULL OR company = ''");
|
||||
$this->addSql('ALTER TABLE prospect ALTER company SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE prospect DROP name');
|
||||
|
||||
// Ownership CHECK constraints: chaque ligne appartient à un client,
|
||||
// un prospect OU un prestataire.
|
||||
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
|
||||
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
|
||||
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
|
||||
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
|
||||
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
|
||||
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Rétablit les contraintes d'ownership client/prospect (sans prestataire).
|
||||
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
|
||||
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
|
||||
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
|
||||
|
||||
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT FK_886919D8BE3DB2B7');
|
||||
$this->addSql('DROP INDEX IDX_886919D8BE3DB2B7');
|
||||
$this->addSql('ALTER TABLE commercial_report DROP prestataire_id');
|
||||
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT FK_6E5D9707BE3DB2B7');
|
||||
$this->addSql('DROP INDEX IDX_6E5D9707BE3DB2B7');
|
||||
$this->addSql('ALTER TABLE directory_address DROP prestataire_id');
|
||||
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT FK_2F711EBEBE3DB2B7');
|
||||
$this->addSql('DROP INDEX IDX_2F711EBEBE3DB2B7');
|
||||
$this->addSql('ALTER TABLE directory_contact DROP prestataire_id');
|
||||
|
||||
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
|
||||
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
|
||||
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
|
||||
|
||||
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A26480DE12AB56');
|
||||
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A2648016FE72E1');
|
||||
$this->addSql('DROP TABLE prestataire');
|
||||
$this->addSql('ALTER TABLE client DROP website');
|
||||
|
||||
// Restaure la colonne name (recopiée depuis company) puis l'oblige.
|
||||
$this->addSql('ALTER TABLE prospect ADD name VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('UPDATE prospect SET name = company');
|
||||
$this->addSql('ALTER TABLE prospect ALTER name SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE prospect DROP website');
|
||||
$this->addSql('ALTER TABLE prospect ALTER company DROP NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ class AppFixtures extends Fixture
|
||||
|
||||
// Prospects
|
||||
$prospectLead = new Prospect();
|
||||
$prospectLead->setName('Marie Dupont');
|
||||
$prospectLead->setCompany('Atelier Dupont');
|
||||
$prospectLead->setEmail('marie@atelier-dupont.fr');
|
||||
$prospectLead->setPhone('06 11 22 33 44');
|
||||
@@ -144,6 +145,7 @@ class AppFixtures extends Fixture
|
||||
$manager->persist($addressLead);
|
||||
|
||||
$prospectQualified = new Prospect();
|
||||
$prospectQualified->setName('Jean Martin');
|
||||
$prospectQualified->setCompany('Martin & Fils');
|
||||
$prospectQualified->setEmail('contact@martin-fils.fr');
|
||||
$prospectQualified->setPhone('07 55 66 77 88');
|
||||
@@ -161,6 +163,7 @@ class AppFixtures extends Fixture
|
||||
$manager->persist($addressQualified);
|
||||
|
||||
$prospectWon = new Prospect();
|
||||
$prospectWon->setName('Sophie Bernard');
|
||||
$prospectWon->setCompany('ACME Corp');
|
||||
$prospectWon->setEmail('contact@acme.com');
|
||||
$prospectWon->setPhone('01 23 45 67 89');
|
||||
|
||||
@@ -111,18 +111,9 @@ class AccrueLeaveCommand extends Command
|
||||
$previousBalance = null !== $previousPeriod
|
||||
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
|
||||
: null;
|
||||
|
||||
if (null !== $previousBalance) {
|
||||
// Only the days *not yet taken* carry over. Leave is charged
|
||||
// oldest-first: it first consumes the previous "acquired"
|
||||
// (N-2) bucket — which expires at roll-over anyway — so only
|
||||
// days taken beyond that bucket eat into the carry-over.
|
||||
$carryOver = $previousBalance->getAcquiring()
|
||||
- max(0.0, $previousBalance->getTaken() - $previousBalance->getAcquired());
|
||||
$balance->setAcquired(max(0.0, $carryOver));
|
||||
} else {
|
||||
$balance->setAcquired($profile->getInitialLeaveBalance());
|
||||
}
|
||||
$balance->setAcquired(
|
||||
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($monthKey === $balance->getLastAccruedMonth()) {
|
||||
|
||||
@@ -14,9 +14,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
*/
|
||||
final class PermissionVoter extends Voter
|
||||
{
|
||||
// Les codes de permission sont au format module.resource.action où chaque
|
||||
// segment peut contenir des tirets (ex. project-management, time-tracking).
|
||||
private const string PATTERN = '/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/';
|
||||
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
|
||||
@@ -38,8 +38,6 @@ final class DirectoryModule implements ModuleInterface
|
||||
['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'],
|
||||
['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'],
|
||||
['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'],
|
||||
['code' => 'directory.providers.view', 'label' => 'Voir les prestataires'],
|
||||
['code' => 'directory.providers.manage', 'label' => 'Gérer les prestataires'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['address:read']],
|
||||
denormalizationContext: ['groups' => ['address:write']],
|
||||
order: ['id' => 'ASC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)]
|
||||
#[ORM\Table(name: 'directory_address')]
|
||||
class Address implements TimestampableInterface, BlamableInterface
|
||||
@@ -80,11 +80,6 @@ class Address implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['address:read', 'address:write'])]
|
||||
private ?Prospect $prospect = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
|
||||
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['address:read', 'address:write'])]
|
||||
private ?Prestataire $prestataire = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -185,16 +180,4 @@ class Address implements TimestampableInterface, BlamableInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrestataire(): ?Prestataire
|
||||
{
|
||||
return $this->prestataire;
|
||||
}
|
||||
|
||||
public function setPrestataire(?Prestataire $prestataire): static
|
||||
{
|
||||
$this->prestataire = $prestataire;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view')"),
|
||||
new Get(security: "is_granted('directory.clients.view')"),
|
||||
new Post(security: "is_granted('directory.clients.manage')"),
|
||||
new Patch(security: "is_granted('directory.clients.manage')"),
|
||||
new Delete(security: "is_granted('directory.clients.manage')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['client:read']],
|
||||
denormalizationContext: ['groups' => ['client:write']],
|
||||
@@ -58,10 +58,6 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
|
||||
#[Groups(['client:read', 'client:write'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write'])]
|
||||
private ?string $website = null;
|
||||
|
||||
/** @var Collection<int, ProjectInterface> */
|
||||
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
|
||||
private Collection $projects;
|
||||
@@ -112,18 +108,6 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): ?string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(?string $website): static
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ProjectInterface> */
|
||||
public function getProjects(): Collection
|
||||
{
|
||||
|
||||
@@ -26,17 +26,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['commercial_report:read']],
|
||||
denormalizationContext: ['groups' => ['commercial_report:write']],
|
||||
order: ['occurredAt' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)]
|
||||
#[ORM\Table(name: 'commercial_report')]
|
||||
class CommercialReport implements TimestampableInterface
|
||||
@@ -80,11 +80,6 @@ class CommercialReport implements TimestampableInterface
|
||||
#[Groups(['commercial_report:read', 'commercial_report:write'])]
|
||||
private ?Prospect $prospect = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
|
||||
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['commercial_report:read', 'commercial_report:write'])]
|
||||
private ?Prestataire $prestataire = null;
|
||||
|
||||
/** @var Collection<int, ReportDocument> */
|
||||
#[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])]
|
||||
#[Groups(['commercial_report:read'])]
|
||||
@@ -184,18 +179,6 @@ class CommercialReport implements TimestampableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrestataire(): ?Prestataire
|
||||
{
|
||||
return $this->prestataire;
|
||||
}
|
||||
|
||||
public function setPrestataire(?Prestataire $prestataire): static
|
||||
{
|
||||
$this->prestataire = $prestataire;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, ReportDocument> */
|
||||
public function getDocuments(): Collection
|
||||
{
|
||||
|
||||
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
|
||||
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['contact:read']],
|
||||
denormalizationContext: ['groups' => ['contact:write']],
|
||||
order: ['lastName' => 'ASC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineContactRepository::class)]
|
||||
#[ORM\Table(name: 'directory_contact')]
|
||||
class Contact implements TimestampableInterface, BlamableInterface
|
||||
@@ -80,11 +80,6 @@ class Contact implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['contact:read', 'contact:write'])]
|
||||
private ?Prospect $prospect = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
|
||||
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups(['contact:read', 'contact:write'])]
|
||||
private ?Prestataire $prestataire = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -185,16 +180,4 @@ class Contact implements TimestampableInterface, BlamableInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrestataire(): ?Prestataire
|
||||
{
|
||||
return $this->prestataire;
|
||||
}
|
||||
|
||||
public function setPrestataire(?Prestataire $prestataire): static
|
||||
{
|
||||
$this->prestataire = $prestataire;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.providers.view')"),
|
||||
new Get(security: "is_granted('directory.providers.view')"),
|
||||
new Post(security: "is_granted('directory.providers.manage')"),
|
||||
new Patch(security: "is_granted('directory.providers.manage')"),
|
||||
new Delete(security: "is_granted('directory.providers.manage')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['prestataire:read']],
|
||||
denormalizationContext: ['groups' => ['prestataire:write']],
|
||||
order: ['name' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrinePrestataireRepository::class)]
|
||||
#[ORM\Table(name: 'prestataire')]
|
||||
class Prestataire implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['prestataire:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['prestataire:read', 'prestataire:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['prestataire:read', 'prestataire:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
#[Groups(['prestataire:read', 'prestataire:write'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['prestataire:read', 'prestataire:write'])]
|
||||
private ?string $website = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(?string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPhone(): ?string
|
||||
{
|
||||
return $this->phone;
|
||||
}
|
||||
|
||||
public function setPhone(?string $phone): static
|
||||
{
|
||||
$this->phone = $phone;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): ?string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(?string $website): static
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -27,20 +27,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[Auditable]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.prospects.view')"),
|
||||
new Get(security: "is_granted('directory.prospects.view')"),
|
||||
new Post(security: "is_granted('directory.prospects.manage')"),
|
||||
new Patch(security: "is_granted('directory.prospects.manage')"),
|
||||
new Delete(security: "is_granted('directory.prospects.manage')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
uriTemplate: '/prospects/{id}/convert',
|
||||
security: "is_granted('directory.prospects.manage')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: ConvertProspectProcessor::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['prospect:read']],
|
||||
denormalizationContext: ['groups' => ['prospect:write']],
|
||||
order: ['company' => 'ASC'],
|
||||
order: ['name' => 'ASC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)]
|
||||
@@ -57,6 +57,10 @@ class Prospect implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['prospect:read', 'prospect:write'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['prospect:read', 'prospect:write'])]
|
||||
private ?string $company = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
@@ -67,10 +71,6 @@ class Prospect implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['prospect:read', 'prospect:write'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['prospect:read', 'prospect:write'])]
|
||||
private ?string $website = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)]
|
||||
#[Groups(['prospect:read', 'prospect:write'])]
|
||||
private ProspectStatus $status = ProspectStatus::New;
|
||||
@@ -93,12 +93,24 @@ class Prospect implements TimestampableInterface, BlamableInterface
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCompany(): ?string
|
||||
{
|
||||
return $this->company;
|
||||
}
|
||||
|
||||
public function setCompany(string $company): static
|
||||
public function setCompany(?string $company): static
|
||||
{
|
||||
$this->company = $company;
|
||||
|
||||
@@ -129,18 +141,6 @@ class Prospect implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWebsite(): ?string
|
||||
{
|
||||
return $this->website;
|
||||
}
|
||||
|
||||
public function setWebsite(?string $website): static
|
||||
{
|
||||
$this->website = $website;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): ProspectStatus
|
||||
{
|
||||
return $this->status;
|
||||
|
||||
@@ -20,14 +20,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
|
||||
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"),
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(
|
||||
security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: ReportDocumentProcessor::class,
|
||||
deserialize: false,
|
||||
),
|
||||
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['report_document:read']],
|
||||
denormalizationContext: ['groups' => ['report_document:write']],
|
||||
|
||||
@@ -9,12 +9,4 @@ use App\Module\Directory\Domain\Entity\Address;
|
||||
interface AddressRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Address;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
* @param null|array<string, string> $orderBy
|
||||
*
|
||||
* @return Address[]
|
||||
*/
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,4 @@ use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
interface CommercialReportRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?CommercialReport;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
* @param null|array<string, string> $orderBy
|
||||
*
|
||||
* @return CommercialReport[]
|
||||
*/
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,4 @@ use App\Module\Directory\Domain\Entity\Contact;
|
||||
interface ContactRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Contact;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
* @param null|array<string, string> $orderBy
|
||||
*
|
||||
* @return Contact[]
|
||||
*/
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Domain\Repository;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||
|
||||
interface PrestataireRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?Prestataire;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $criteria
|
||||
* @param null|array<string, string> $orderBy
|
||||
*
|
||||
* @return Prestataire[]
|
||||
*/
|
||||
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||
}
|
||||
@@ -46,10 +46,9 @@ final readonly class ConvertProspectProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
$client = new Client();
|
||||
$client->setName((string) $prospect->getCompany());
|
||||
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
|
||||
$client->setEmail($prospect->getEmail());
|
||||
$client->setPhone($prospect->getPhone());
|
||||
$client->setWebsite($prospect->getWebsite());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Prestataire>
|
||||
*/
|
||||
final class DoctrinePrestataireRepository extends ServiceEntityRepository implements PrestataireRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Prestataire::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Prestataire
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
@@ -42,10 +42,9 @@ class ConvertProspectTool
|
||||
|
||||
if (null === $prospect->getConvertedClient()) {
|
||||
$client = new Client();
|
||||
$client->setName((string) $prospect->getCompany());
|
||||
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
|
||||
$client->setEmail($prospect->getEmail());
|
||||
$client->setPhone($prospect->getPhone());
|
||||
$client->setWebsite($prospect->getWebsite());
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Address;
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create-address',
|
||||
description: 'Create an address (admin) attached to exactly one of clientId / prospectId / prestataireId. Country defaults to FR (ISO 3166 alpha-2).'
|
||||
)]
|
||||
class CreateAddressTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
?string $label = null,
|
||||
?string $street = null,
|
||||
?string $streetComplement = null,
|
||||
?string $postalCode = null,
|
||||
?string $city = null,
|
||||
?string $country = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (1 !== count($parents)) {
|
||||
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||
}
|
||||
|
||||
$address = new Address();
|
||||
|
||||
if (null !== $clientId) {
|
||||
$client = $this->clientRepository->findById($clientId);
|
||||
if (null === $client) {
|
||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||
}
|
||||
$address->setClient($client);
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$prospect = $this->prospectRepository->findById($prospectId);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||
}
|
||||
$address->setProspect($prospect);
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||
if (null === $prestataire) {
|
||||
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||
}
|
||||
$address->setPrestataire($prestataire);
|
||||
}
|
||||
|
||||
$address->setLabel($label);
|
||||
$address->setStreet($street);
|
||||
$address->setStreetComplement($streetComplement);
|
||||
$address->setPostalCode($postalCode);
|
||||
$address->setCity($city);
|
||||
if (null !== $country) {
|
||||
if (2 !== strlen($country)) {
|
||||
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
|
||||
}
|
||||
$address->setCountry(strtoupper($country));
|
||||
}
|
||||
|
||||
$this->entityManager->persist($address);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::address($address));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ class CreateClientTool
|
||||
string $name,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $website = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
@@ -33,7 +32,6 @@ class CreateClientTool
|
||||
$client->setName($name);
|
||||
$client->setEmail($email);
|
||||
$client->setPhone($phone);
|
||||
$client->setWebsite($website);
|
||||
|
||||
$this->entityManager->persist($client);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Enum\ReportType;
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create-commercial-report',
|
||||
description: 'Create a commercial report (admin) attached to exactly one of clientId / prospectId / prestataireId. Type defaults to "note". Allowed types: note, call, meeting, email. Date defaults to today if omitted.'
|
||||
)]
|
||||
class CreateCommercialReportTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $subject,
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
?string $body = null,
|
||||
?string $occurredAt = null,
|
||||
?string $type = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (1 !== count($parents)) {
|
||||
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||
}
|
||||
|
||||
$report = new CommercialReport();
|
||||
$report->setSubject($subject);
|
||||
$report->setBody($body);
|
||||
|
||||
if (null !== $clientId) {
|
||||
$client = $this->clientRepository->findById($clientId);
|
||||
if (null === $client) {
|
||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||
}
|
||||
$report->setClient($client);
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$prospect = $this->prospectRepository->findById($prospectId);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||
}
|
||||
$report->setProspect($prospect);
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||
if (null === $prestataire) {
|
||||
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||
}
|
||||
$report->setPrestataire($prestataire);
|
||||
}
|
||||
|
||||
try {
|
||||
$date = null === $occurredAt
|
||||
? new DateTimeImmutable('today')
|
||||
: new DateTimeImmutable($occurredAt);
|
||||
} catch (Exception $e) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
|
||||
}
|
||||
$report->setOccurredAt($date);
|
||||
|
||||
if (null !== $type) {
|
||||
$typeEnum = ReportType::tryFrom($type);
|
||||
if (null === $typeEnum) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
|
||||
}
|
||||
$report->setType($typeEnum);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($report);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::commercialReport($report));
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Contact;
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create-contact',
|
||||
description: 'Create a contact (admin) attached to exactly one of clientId / prospectId / prestataireId. All fields except the parent are optional.'
|
||||
)]
|
||||
class CreateContactTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
?string $firstName = null,
|
||||
?string $lastName = null,
|
||||
?string $jobTitle = null,
|
||||
?string $email = null,
|
||||
?string $phonePrimary = null,
|
||||
?string $phoneSecondary = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (1 !== count($parents)) {
|
||||
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||
}
|
||||
|
||||
$contact = new Contact();
|
||||
|
||||
if (null !== $clientId) {
|
||||
$client = $this->clientRepository->findById($clientId);
|
||||
if (null === $client) {
|
||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||
}
|
||||
$contact->setClient($client);
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$prospect = $this->prospectRepository->findById($prospectId);
|
||||
if (null === $prospect) {
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||
}
|
||||
$contact->setProspect($prospect);
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||
if (null === $prestataire) {
|
||||
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||
}
|
||||
$contact->setPrestataire($prestataire);
|
||||
}
|
||||
|
||||
$contact->setFirstName($firstName);
|
||||
$contact->setLastName($lastName);
|
||||
$contact->setJobTitle($jobTitle);
|
||||
$contact->setEmail($email);
|
||||
$contact->setPhonePrimary($phonePrimary);
|
||||
$contact->setPhoneSecondary($phoneSecondary);
|
||||
|
||||
$this->entityManager->persist($contact);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::contact($contact));
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'create-prestataire', description: 'Create a prestataire / service provider (admin). Only name is required.')]
|
||||
class CreatePrestataireTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $website = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$prestataire = new Prestataire();
|
||||
$prestataire->setName($name);
|
||||
$prestataire->setEmail($email);
|
||||
$prestataire->setPhone($phone);
|
||||
$prestataire->setWebsite($website);
|
||||
|
||||
$this->entityManager->persist($prestataire);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::prestataire($prestataire));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only company is required. Status defaults to "new".')]
|
||||
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only name is required. Status defaults to "new".')]
|
||||
class CreateProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
@@ -24,10 +24,10 @@ class CreateProspectTool
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $company,
|
||||
string $name,
|
||||
?string $company = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
?string $website = null,
|
||||
?string $status = null,
|
||||
?string $source = null,
|
||||
?string $notes = null,
|
||||
@@ -37,10 +37,10 @@ class CreateProspectTool
|
||||
}
|
||||
|
||||
$prospect = new Prospect();
|
||||
$prospect->setName($name);
|
||||
$prospect->setCompany($company);
|
||||
$prospect->setEmail($email);
|
||||
$prospect->setPhone($phone);
|
||||
$prospect->setWebsite($website);
|
||||
$prospect->setSource($source);
|
||||
$prospect->setNotes($notes);
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-address', description: 'Delete an address (admin).')]
|
||||
class DeleteAddressTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$address = $this->addressRepository->findById($id);
|
||||
if (null === $address) {
|
||||
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($address);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('Address #%d deleted.', $id)]);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-commercial-report', description: 'Delete a commercial report (admin). Cascade removes its attached documents.')]
|
||||
class DeleteCommercialReportTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$report = $this->reportRepository->findById($id);
|
||||
if (null === $report) {
|
||||
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($report);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('CommercialReport #%d deleted.', $id)]);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-contact', description: 'Delete a contact (admin).')]
|
||||
class DeleteContactTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$contact = $this->contactRepository->findById($id);
|
||||
if (null === $contact) {
|
||||
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($contact);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('Contact #%d deleted.', $id)]);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-prestataire', description: 'Delete a prestataire (admin). Cascade removes its contacts, addresses and reports.')]
|
||||
class DeletePrestataireTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$prestataire = $this->prestataireRepository->findById($id);
|
||||
if (null === $prestataire) {
|
||||
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$name = $prestataire->getName();
|
||||
$this->entityManager->remove($prestataire);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('Prestataire "%s" deleted.', $name)]);
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,10 @@ class DeleteProspectTool
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$company = $prospect->getCompany();
|
||||
$name = $prospect->getName();
|
||||
$this->entityManager->remove($prospect);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $company)]);
|
||||
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $name)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-address', description: 'Get an address by ID.')]
|
||||
class GetAddressTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$address = $this->addressRepository->findById($id);
|
||||
if (null === $address) {
|
||||
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::address($address));
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -16,14 +13,11 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-client', description: 'Get a client by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
|
||||
class GetClientTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientRepositoryInterface $clientRepository,
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
@@ -38,20 +32,6 @@ class GetClientTool
|
||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$payload = Serializer::client($client);
|
||||
$payload['contacts'] = array_map(
|
||||
fn ($c) => Serializer::contact($c),
|
||||
$this->contactRepository->findBy(['client' => $client], ['lastName' => 'ASC'])
|
||||
);
|
||||
$payload['addresses'] = array_map(
|
||||
fn ($a) => Serializer::address($a),
|
||||
$this->addressRepository->findBy(['client' => $client], ['id' => 'ASC'])
|
||||
);
|
||||
$payload['reports'] = array_map(
|
||||
fn ($r) => Serializer::commercialReport($r),
|
||||
$this->reportRepository->findBy(['client' => $client], ['occurredAt' => 'DESC'])
|
||||
);
|
||||
|
||||
return json_encode($payload);
|
||||
return json_encode(Serializer::client($client));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-commercial-report', description: 'Get a commercial report by ID, including its attached documents.')]
|
||||
class GetCommercialReportTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$report = $this->reportRepository->findById($id);
|
||||
if (null === $report) {
|
||||
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::commercialReport($report));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-contact', description: 'Get a contact by ID.')]
|
||||
class GetContactTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$contact = $this->contactRepository->findById($id);
|
||||
if (null === $contact) {
|
||||
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::contact($contact));
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-prestataire', description: 'Get a prestataire by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||
class GetPrestataireTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$prestataire = $this->prestataireRepository->findById($id);
|
||||
if (null === $prestataire) {
|
||||
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$payload = Serializer::prestataire($prestataire);
|
||||
$payload['contacts'] = array_map(
|
||||
fn ($c) => Serializer::contact($c),
|
||||
$this->contactRepository->findBy(['prestataire' => $prestataire], ['lastName' => 'ASC'])
|
||||
);
|
||||
$payload['addresses'] = array_map(
|
||||
fn ($a) => Serializer::address($a),
|
||||
$this->addressRepository->findBy(['prestataire' => $prestataire], ['id' => 'ASC'])
|
||||
);
|
||||
$payload['reports'] = array_map(
|
||||
fn ($r) => Serializer::commercialReport($r),
|
||||
$this->reportRepository->findBy(['prestataire' => $prestataire], ['occurredAt' => 'DESC'])
|
||||
);
|
||||
|
||||
return json_encode($payload);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
@@ -16,14 +13,11 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
|
||||
class GetProspectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
@@ -38,20 +32,6 @@ class GetProspectTool
|
||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$payload = Serializer::prospect($prospect);
|
||||
$payload['contacts'] = array_map(
|
||||
fn ($c) => Serializer::contact($c),
|
||||
$this->contactRepository->findBy(['prospect' => $prospect], ['lastName' => 'ASC'])
|
||||
);
|
||||
$payload['addresses'] = array_map(
|
||||
fn ($a) => Serializer::address($a),
|
||||
$this->addressRepository->findBy(['prospect' => $prospect], ['id' => 'ASC'])
|
||||
);
|
||||
$payload['reports'] = array_map(
|
||||
fn ($r) => Serializer::commercialReport($r),
|
||||
$this->reportRepository->findBy(['prospect' => $prospect], ['occurredAt' => 'DESC'])
|
||||
);
|
||||
|
||||
return json_encode($payload);
|
||||
return json_encode(Serializer::prospect($prospect));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list-addresses',
|
||||
description: 'List addresses, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||
)]
|
||||
class ListAddressesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AddressRepositoryInterface $addressRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (count($filters) > 1) {
|
||||
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $clientId) {
|
||||
$criteria['client'] = $clientId;
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$criteria['prospect'] = $prospectId;
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$criteria['prestataire'] = $prestataireId;
|
||||
}
|
||||
|
||||
$addresses = $this->addressRepository->findBy($criteria, ['id' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($a) => Serializer::address($a), $addresses));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list-commercial-reports',
|
||||
description: 'List commercial reports, optionally filtered by parent (at most one of clientId / prospectId / prestataireId). Returns reports ordered by occurredAt DESC.'
|
||||
)]
|
||||
class ListCommercialReportsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (count($filters) > 1) {
|
||||
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $clientId) {
|
||||
$criteria['client'] = $clientId;
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$criteria['prospect'] = $prospectId;
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$criteria['prestataire'] = $prestataireId;
|
||||
}
|
||||
|
||||
$reports = $this->reportRepository->findBy($criteria, ['occurredAt' => 'DESC']);
|
||||
|
||||
return json_encode(array_map(fn ($r) => Serializer::commercialReport($r), $reports));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list-contacts',
|
||||
description: 'List contacts, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||
)]
|
||||
class ListContactsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContactRepositoryInterface $contactRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $clientId = null,
|
||||
?int $prospectId = null,
|
||||
?int $prestataireId = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||
if (count($filters) > 1) {
|
||||
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $clientId) {
|
||||
$criteria['client'] = $clientId;
|
||||
}
|
||||
if (null !== $prospectId) {
|
||||
$criteria['prospect'] = $prospectId;
|
||||
}
|
||||
if (null !== $prestataireId) {
|
||||
$criteria['prestataire'] = $prestataireId;
|
||||
}
|
||||
|
||||
$contacts = $this->contactRepository->findBy($criteria, ['lastName' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($c) => Serializer::contact($c), $contacts));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user