Compare commits

..

16 Commits

Author SHA1 Message Date
gitea-actions 6710c3015e chore: bump version to v0.4.34
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 53s
2026-06-23 15:46:57 +00:00
matthieu b6dd3ad194 Merge pull request 'RBAC : enforcement des permissions granulaires + suppression client/prospect' (#20) from feat/rbac-enforcement into develop
Auto Tag Develop / tag (push) Successful in 13s
Reviewed-on: #20
2026-06-23 15:46:46 +00:00
matthieu b4062618f7 Merge branch 'develop' into feat/rbac-enforcement
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m8s
2026-06-23 15:46:41 +00:00
Matthieu 3d991f78e5 feat(directory) : add client/prospect deletion from list with confirm modal
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m35s
2026-06-23 17:38:17 +02:00
gitea-actions 3294b0c361 chore: bump version to v0.4.33
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 35s
2026-06-23 15:15:20 +00:00
matthieu 46e23874bd Merge pull request 'fix(rbac) : appliquer les permissions granulaires sur les ressources métier' (#19) from feat/rbac-enforcement into develop
Auto Tag Develop / tag (push) Successful in 12s
Reviewed-on: #19
2026-06-23 15:15:07 +00:00
Matthieu 4a7fd46493 fix(rbac) : add dedicated time-tracking.entries.manage permission
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m15s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m32s
La revue de sécurité a relevé que les écritures de TimeEntry (Post/Patch/Delete)
étaient gardées par time-tracking.entries.view : une permission de lecture
accordait l'écriture (confusion lecture/écriture, least-privilege).

- Ajout de la permission time-tracking.entries.manage (catalogue cohérent avec
  les autres modules en view/manage).
- Écritures TimeEntry recâblées sur entries.manage ; self-service conservé
  (object.getUser() == user). Lecture inchangée (entries.view).
2026-06-23 17:10:58 +02:00
Matthieu 5e3607658a refactor(directory) : reduce client/prospect forms to company name
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 4m39s
Les formulaires d'ajout/édition client et prospect ne conservent que le champ
« Nom société ». Les coordonnées (email, téléphone) et les champs prospect
(société, statut, source, notes) sont retirés : ils seront gérés via Contact.
Le statut prospect prend son défaut New à la création ; DTO assouplis, payload
réduit à { name }.
2026-06-23 17:06:04 +02:00
Matthieu 9705b335ef fix(rbac) : enforce granular permissions on business resources
Les ressources métier (ProjectManagement, Directory, TimeTracking) étaient
gardées par is_granted('ROLE_USER')/'ROLE_ADMIN', ignorant les permissions
RBAC granulaires déclarées par les modules : un utilisateur sans permission
voyait quand même projets, tâches, clients, etc.

- PermissionVoter : le regex excluait les tirets, donc project-management.* et
  time-tracking.* n'étaient supportées par aucun voter (refus pour tous, admin
  compris car le bypass ROLE_ADMIN est interne au voter). Ajout du tiret.
- Câblage des permissions *.view (lecture) / *.manage (écriture) sur les 17
  ressources métier. Métadonnées tâches lisibles via projects.view OR tasks.view.
  Directory partagé client/prospect via clients.* OR prospects.*. TimeEntry
  conserve le self-service (object.getUser() == user).
- Sidebar : gating par permission effective des onglets Projets / Mes tâches /
  Suivi du temps (config/sidebar.php).
- Test fonctionnel ProjectAccessControlTest (0 perm -> 403, view -> 200,
  view ne donne pas l'écriture -> 403).
2026-06-23 17:05:33 +02:00
gitea-actions 903030afbc chore: bump version to v0.4.32
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m58s
2026-06-23 14:25:40 +00:00
matthieu 961b7f56b4 Merge pull request 'Directory : save contacts/adresses au clic, denormalizer interface & seed RBAC au déploiement' (#18) from feat/directory-detail-save into develop
Auto Tag Develop / tag (push) Successful in 7s
Reviewed-on: #18
2026-06-23 14:25:33 +00:00
Matthieu 8e00c5f5a8 fix(deploy) : seed RBAC system roles during deployment
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m8s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m37s
deploy.sh only synced the permission catalog; the system roles (admin, user)
were never seeded, leaving the admin Roles page empty after a fresh deploy.
Add app:seed-rbac (idempotent) to the deploy script, refresh the embedded
script in deployment-docker.md, document the RBAC post-deploy step (with the
manual fix for an already-deployed prod), and note it in CLAUDE.md.
2026-06-23 16:11:58 +02:00
Matthieu f2d945b0c3 fix(directory) : persist contacts/addresses on explicit save instead of on blur
Hold contact/address block edits in memory and persist them via explicit
saveContacts/saveAddresses on click (with saving guards), matching the task
forms. Keep immediate deletion. Minor restyle of blocks and action buttons.
2026-06-23 16:04:02 +02:00
Matthieu 610e99eeb9 fix(api) : denormalize interface-typed relation collections
Add ContractRelationDenormalizer to resolve IRIs for collections typed
against an interface (Contract), fixing POST/PATCH 400 errors. Cover it
with InterfaceCollectionDenormalizationTest.
2026-06-23 16:03:32 +02:00
gitea-actions 932fccf75f chore: bump version to v0.4.31
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m48s
2026-06-23 13:50:52 +00:00
matthieu 8313c759c6 Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
2026-06-23 13:50:42 +00:00
520 changed files with 14467 additions and 2703 deletions
+115
View File
@@ -0,0 +1,115 @@
name: Pull Request — Quality gate
# Lance les tests back + le build front sur chaque PR ciblant develop.
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile".
on:
pull_request:
branches:
- develop
# Annule les runs obsoletes quand on repush sur la meme PR.
concurrency:
group: pr-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
backend:
name: Backend (PHP CS + PHPUnit)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
# Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le
# suffixe `_test` automatiquement en APP_ENV=test (when@test
# dbname_suffix) → la base reellement utilisee est `app_test`.
POSTGRES_USER: app
POSTGRES_PASSWORD: '!ChangeMe!'
POSTGRES_DB: app
# Pas de `ports:` host mapping : les jobs Gitea Actions tournent en
# container sur un reseau Docker dedie, le service est joignable via
# son nom (`postgres`), pas via 127.0.0.1.
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
APP_ENV: test
APP_SECRET: ci-secret-not-used
APP_DEBUG: 0
DEFAULT_URI: http://localhost/
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
JWT_PASSPHRASE: ci-passphrase
# Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que
# les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test
# alignee sur phpunit.dist.xml.
ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
# zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par
# le chiffrement des secrets, ctype/iconv par le require de composer.json.
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv
coverage: none
tools: composer:v2
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Generate JWT keypair
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
# Miroir de la cible `db-reset` du makefile (create + migrate + fixtures),
# en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ;
# sync-permissions complete le catalogue de permissions comme en install reelle.
run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
frontend:
name: Frontend (build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
# `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/).
- name: Install Node dependencies
run: npm ci
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender n'apporte rien a une quality gate — on valide seulement
# que le bundle compile.
- name: Build production (nuxt build)
run: npm run build
+6
View File
@@ -126,6 +126,12 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures ## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN) - User admin : `admin` / `admin` (ROLE_ADMIN)
+14
View File
@@ -7,8 +7,22 @@ declare(strict_types=1);
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules. * Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/ */
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
use App\Module\Mail\MailModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\Reporting\ReportingModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [ return [
CoreModule::class, CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
AbsenceModule::class,
DirectoryModule::class,
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
]; ];
+14
View File
@@ -1,6 +1,20 @@
api_platform: api_platform:
title: Lesstime API title: Lesstime API
version: 1.0.0 version: 1.0.0
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
# discovered via service autoconfiguration, but #[ApiFilter] services are only
# registered for classes found in these paths — without them, every filter is
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
formats: formats:
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']
json: ['application/json'] json: ['application/json']
+34 -6
View File
@@ -22,18 +22,46 @@ doctrine:
auto_mapping: true auto_mapping: true
resolve_target_entities: resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
mappings: mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core: Core:
type: attribute type: attribute
is_bundle: false is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity' dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity' prefix: 'App\Module\Core\Domain\Entity'
TimeTracking:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
prefix: 'App\Module\TimeTracking\Domain\Entity'
ProjectManagement:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
prefix: 'App\Module\ProjectManagement\Domain\Entity'
Absence:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
prefix: 'App\Module\Absence\Domain\Entity'
Directory:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
prefix: 'App\Module\Directory\Domain\Entity'
Mail:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
prefix: 'App\Module\Mail\Domain\Entity'
Integration:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
prefix: 'App\Module\Integration\Domain\Entity'
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false
+1 -1
View File
@@ -23,7 +23,7 @@ framework:
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS # messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si # (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
# la boîte grossit au point que la sync à la demande approche le timeout PHP. # la boîte grossit au point que la sync à la demande approche le timeout PHP.
'App\Message\MailSyncRequested': sync 'App\Module\Mail\Application\Message\MailSyncRequested': sync
when@test: when@test:
framework: framework:
+77 -9
View File
@@ -31,41 +31,51 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\EventListener\TaskDocumentListener: App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
tags: tags:
- { name: doctrine.orm.entity_listener } - { name: doctrine.orm.entity_listener }
App\State\TaskDocumentProcessor: App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Controller\TaskDocumentDownloadController: App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\AddTaskDocumentTool: App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\UpdateTaskDocumentTool: App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController: App\Module\Core\Infrastructure\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Controller\Absence\AbsenceJustificationUploadController: App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Controller\Absence\AbsenceJustificationDownloadController: App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource' App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository' App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
@@ -73,4 +83,62 @@ services:
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository' App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
tags:
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\Directory\Infrastructure\Controller\ReportDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
tags:
- { name: doctrine.orm.entity_listener }
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailFolderRepository'
App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailMessageRepository'
App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineTaskMailLinkRepository'
App\Module\Mail\Domain\Provider\MailProviderInterface: '@App\Module\Mail\Infrastructure\Imap\ImapMailProvider'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier' App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+12 -5
View File
@@ -10,8 +10,11 @@ declare(strict_types=1);
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin — * - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la * `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin). * sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag * Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint. * (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
* pour éviter le doublon.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>). * Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/ */
return [ return [
@@ -20,9 +23,11 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'items' => [ 'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'], ['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'], ['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'], ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
// 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'],
], ],
], ],
[ [
@@ -30,8 +35,10 @@ return [
'icon' => 'mdi:cog-outline', 'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'], 'roles' => ['ROLE_ADMIN'],
'items' => [ 'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'], ['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.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.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
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.30' app.version: '0.4.34'
+31 -1
View File
@@ -128,6 +128,12 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..." echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -294,7 +300,31 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique ./deploy.sh v0.3.13 # deploie une version specifique
``` ```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache. 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
```
--- ---
@@ -0,0 +1,66 @@
# LST-58 (2.4) — Module Directory : Prospect + front répertoire (plan)
> Suite de la migration Directory. Client (back) déjà livré (`c5738d2`).
> Reste : **entité Prospect** (nouvelle) + **front répertoire** (Clients + Prospects).
> Spec produit non fournie → design défini ici de façon raisonnable, à valider au test.
> Additif, sans régression. Branche `integration/modular-monolith-0.1-1.3`.
## Design Prospect (décidé, à valider)
Aligné sur `Client` (même module Directory), enrichi des concepts de prospection commerciale.
**Entité `App\Module\Directory\Domain\Entity\Prospect`** (table `prospect`) :
- `id` int PK
- `name` string(255) NOT NULL — contact ou société
- `company` string(255) nullable
- `email` string(255) nullable
- `phone` string(50) nullable
- `street` string(255) nullable / `city` string(255) nullable / `postalCode` string(20) nullable (alignés Client)
- `status` enum `ProspectStatus` NOT NULL (default `New`)
- `source` string(255) nullable — origine (recommandation, salon, site web…)
- `notes` text nullable
- `convertedClient` ManyToOne `ClientInterface` nullable, JoinColumn ON DELETE SET NULL — rempli à la conversion
- Timestampable/Blamable (trait) + `#[Auditable]`
- Groupes : `prospect:read` / `prospect:write`
**Enum `App\Module\Directory\Domain\Enum\ProspectStatus`** : `New` (nouveau), `Contacted` (contacté), `Qualified` (qualifié), `Won` (gagné/converti), `Lost` (perdu). Méthode `label(): string` (FR), comme les autres enums.
**API Platform** (aligné Client) :
- `GetCollection` paginationEnabled:false, `is_granted('ROLE_USER')`
- `Get` ROLE_USER ; `Post`/`Patch`/`Delete` ROLE_ADMIN
- Opération custom **`Post /prospects/{id}/convert`** (processor `ConvertProspectProcessor`) : crée un `Client` à partir du Prospect (name/company→name, email, phone, adresse), lie `convertedClient`, passe `status=Won`. Sécurité ROLE_ADMIN. Renvoie le Prospect mis à jour. Idempotent si déjà converti (renvoie l'existant).
- `#[ApiFilter]` SearchFilter sur `status` (filtre répertoire).
**Repo** : `ProspectRepositoryInterface` (Domain) + `DoctrineProspectRepository` (Infra) + binding.
**MCP** (cohérent avec clients, sous `Infrastructure/Mcp/Tool/`) : `list-prospects`, `get-prospect`, `create-prospect`, `update-prospect`, `delete-prospect`, `convert-prospect`. Serializer : ajouter `prospect()` dans `src/Mcp/Tool/Serializer.php`.
**DirectoryModule.permissions()** : ajouter `directory.prospects.view`, `directory.prospects.manage` (additif).
**Migration additive** : CREATE TABLE prospect (colonnes + FK converted_client→client ON DELETE SET NULL + created_by/updated_by FK user + index + COMMENT). Down = DROP TABLE.
**Fixtures** : 2-3 prospects de démo (statuts variés), dont un converti.
## Front répertoire (`frontend/modules/directory/`)
Aujourd'hui : pas de page client dédiée (AdminClientTab + picker ProjectDrawer). On crée un vrai répertoire.
- `nuxt.config.ts` vide.
- `services/` : `clients.ts` (move depuis racine), `prospects.ts` (nouveau) + `dto/{client,prospect}.ts`.
- `pages/directory.vue` : page à 2 onglets (Clients / Prospects), tableaux paginés côté client (paginationEnabled:false back), recherche/filtre statut pour prospects.
- `components/` : `ClientDrawer.vue` (move depuis `components/client/`), `ProspectDrawer.vue` (nouveau, create/edit + bouton « Convertir en client »).
- Sidebar : ajouter item `sidebar.general.directory``/directory`, `'module' => 'directory'`, gate ROLE_ADMIN (gestion référentiel).
- Réécrire imports consommateurs de `~/services/clients` / `~/services/dto/client` (AdminClientTab, ProjectDrawer, pages projects) → `~/modules/directory/services/...`. AdminClientTab : soit le retirer de /admin au profit de /directory, soit le laisser pointer le nouveau service. Décision : garder AdminClientTab fonctionnel (repoint service) ET ajouter la page /directory (les deux coexistent ; /directory = vue dédiée).
- i18n global : ajouter clés `directory.*`, `prospects.*`, `sidebar.general.directory`.
## Vagues d'exécution
1. **Back Prospect** : enum + entité + repo + API (CRUD + convert) + MCP (6 tools) + Serializer + permissions module + fixtures + migration. Vérif cache:clear/migrate/phpunit/cs-fixer → commit.
2. **Front Directory** : layer (move client front + page répertoire + ProspectDrawer + prospects service/dto) + sidebar + imports + i18n. Vérif nuxt build → commit.
## Critères d'acceptation (ticket #58)
- [x] Clients en module (fait, c5738d2)
- [ ] Prospects en module + front répertoire fonctionnel
- [x] resolve_target_entities → Directory\Client
- [ ] make test vert, aucune migration destructive
- [ ] toggle module directory (sidebar + route /directory)
## Suite phase 2 (après 2.4)
- 2.5 (#67) Module Mail — WIP `docs/mail-integration.md`, à traiter avec précaution.
- 2.6 (#68) Module Integration (Gitea/BookStack/Zimbra/Share).
@@ -0,0 +1,82 @@
# LST-65 (2.2) — Module ProjectManagement : plan de migration
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
## Périmètre (10 entités + écosystème)
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
Enums : StatusCategory, RecurrenceType.
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
## Décisions d'architecture (figées)
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
- PAS de WorkflowInterface (Workflow est intra-module PM).
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X``App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
5. **Services CalDavService + RecurrenceCalculator**`Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
9. **resolve_target_entities** final :
```
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
```
---
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
## Tranche 2 — Move mécanique vers le module
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
## Tranche 3 — Timestampable additif (Task + Project)
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
3. Champs hors groupes API existants (le trait porte ses propres groupes).
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
## Tranche 4 — Front layer project-management
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
## Critères d'acceptation (ticket)
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
- [ ] `make test` vert, aucune migration destructive.
- [ ] Toggle module project-management (sidebar + routes) prouvé.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
# Répertoire — Contacts, Adresses & Rapports commerciaux
**Date :** 2026-06-22
**Module :** `Directory` (Lesstime)
**Statut :** Conception validée — prêt pour plan d'implémentation
## Contexte & objectif
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
type d'échange + auteur) auquel on peut **joindre des documents**.
Décisions cadrées avec l'utilisateur :
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
## Approche retenue
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
FK** (pas de copie), ce qui préserve l'historique.
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
## Modèle de données (backend — `src/Module/Directory`)
Toutes les nouvelles entités vivent dans le module `Directory`
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
sont `#[Auditable]` comme `Client`/`Prospect`.
### `Contact` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| firstName | string? | |
| lastName | string? | |
| jobTitle | string? | fonction |
| email | string? | lowercase |
| phonePrimary | string? | |
| phoneSecondary | string? | |
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
`isContactNamed` de Starseed).
### `Address` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| label | string? | libellé libre (« Siège », « Facturation »…) |
| street | string? | |
| streetComplement | string? | |
| postalCode | string? | |
| city | string? | |
| country | string | défaut `FR` |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
### `CommercialReport` (compte-rendu, répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| subject | string | objet du compte-rendu |
| body | text | le compte-rendu lui-même |
| occurredAt | date | date de l'échange |
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
### Migration de l'adresse *inline*
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
restent sur `Client`/`Prospect` (identité principale).
### Documents des comptes-rendus
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
> recouple `ProjectManagement` ↔ `Directory`.
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
croisée `ProjectManagement``Directory`) au prix d'une duplication maîtrisée du processor
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
dépendent que du DTO document + de l'URL de download).
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
`GetCollection` filtrable par `commercialReport`.
## API Platform
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
`author` (id + username) et `documents`.
Permissions RBAC ajoutées au `Module::permissions()` :
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
`directory.clients.*` / `directory.prospects.*` existants.)
## Conversion prospect → client
`ConvertProspectProcessor`
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
automatiquement (rattachés au `CommercialReport`, pas au prospect).
## Frontend (Nuxt — `frontend/modules/directory`)
### Liste & navigation
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
### Fiches détail
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
- Bloc principal (identité : name/email/phone…), éditable en place.
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
### Services & DTO
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
## i18n
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
## Tests (PHPUnit)
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
client (`prospect = null`), idempotence.
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
ayant une adresse non vide).
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
> (relations, statuts, présence), pas des **counts absolus**.
## Hors périmètre (YAGNI)
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
- Pas de dashboard/statistiques commerciales chiffrées.
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.
+12 -7
View File
@@ -125,8 +125,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
@@ -139,15 +139,20 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const { sections } = useSidebar() 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(() => const translatedSections = computed(() =>
sections.value.map((section) => ({ sections.value.map((section) => ({
label: t(section.label), label: t(section.label),
icon: section.icon, icon: section.icon,
items: section.items.map((item) => ({ items: section.items
label: t(item.label), .filter((item) => item.to !== '/mail')
to: item.to, .map((item) => ({
icon: item.icon, label: t(item.label),
})), to: item.to,
icon: item.icon,
})),
})), })),
) )
@@ -51,8 +51,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy } from '~/services/dto/absence' import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
const service = useAbsenceService() const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([]) const rows = ref<AbsencePolicy[]>([])
@@ -51,7 +51,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBookStackService } from '~/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService() const { getSettings, saveSettings, testConnection } = useBookStackService()
+2 -2
View File
@@ -40,8 +40,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client } from '~/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/services/clients' import { useClientService } from '~/modules/directory/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+2 -2
View File
@@ -30,8 +30,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -45,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/modules/integration/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService() const { getSettings, saveSettings, testConnection } = useGiteaService()
+1 -1
View File
@@ -140,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMailService } from '~/services/mail' import { useMailService } from '~/modules/mail/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService() const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities' import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -70,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useShareSettingsService } from '~/services/share-settings' import { useShareSettingsService } from '~/modules/integration/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService() const { getSettings, saveSettings, testConnection } = useShareSettingsService()
+2 -2
View File
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag } from '~/services/dto/task-tag' import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags' import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow' import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n() const { t } = useI18n()
+1 -1
View File
@@ -58,7 +58,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useZimbraService } from '~/services/zimbra' import { useZimbraService } from '~/modules/integration/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService() const { getSettings, saveSettings, testConnection } = useZimbraService()
+5 -5
View File
@@ -96,11 +96,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow' import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow' import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status' import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
const { t } = useI18n() const { t } = useI18n()
@@ -167,8 +167,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FileEntry } from '~/services/dto/share' import type { FileEntry } from '~/modules/integration/services/dto/share'
import { useShareService } from '~/services/share' import { useShareService } from '~/modules/integration/services/share'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
+33 -20
View File
@@ -106,30 +106,43 @@ const touched = reactive({
password: false, password: false,
}) })
watch(() => props.modelValue, (open) => { const { create, update, getById } = useUserService()
if (open) {
if (props.item) { function applyUser(user: UserData) {
form.username = props.item.username ?? '' form.username = user.username ?? ''
form.firstName = props.item.firstName ?? '' form.firstName = user.firstName ?? ''
form.lastName = props.item.lastName ?? '' form.lastName = user.lastName ?? ''
form.password = '' form.password = ''
form.roles = [...props.item.roles] form.roles = [...user.roles]
form.isEmployee = props.item.isEmployee ?? false form.isEmployee = user.isEmployee ?? false
} else { }
form.username = ''
form.firstName = '' watch(() => props.modelValue, async (open) => {
form.lastName = '' if (!open) {
form.password = '' return
form.roles = ['ROLE_USER'] }
form.isEmployee = false
touched.username = false
touched.password = false
if (props.item) {
applyUser(props.item)
try {
const full = await getById(props.item.id)
applyUser(full)
} catch {
// Keep the list data if the detailed fetch fails.
} }
touched.username = false } else {
touched.password = false form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
} }
}) })
const { create, update } = useUserService()
async function handleSubmit() { async function handleSubmit() {
touched.username = true touched.username = true
touched.password = true touched.password = true
File diff suppressed because it is too large Load Diff
@@ -19,8 +19,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence' import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -73,8 +73,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance } from '~/services/dto/absence' import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
balances: AbsenceBalance[] balances: AbsenceBalance[]
@@ -52,9 +52,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence' import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
absences: AbsenceRequest[] absences: AbsenceRequest[]
@@ -29,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { HalfDay } from '~/services/dto/absence' import type { HalfDay } from '~/modules/absence/services/dto/absence'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */ /** ISO date string "YYYY-MM-DD" or null. */
@@ -135,9 +135,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence' import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -26,9 +26,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/services/dto/absence' import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -105,9 +105,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence' import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/services/absences' import { useAbsenceService } from '~/modules/absence/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,4 +1,4 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence' import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger' export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -69,9 +69,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/services/dto/absence' import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/modules/absence/services/dto/absence'
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences' import { useAbsenceService, type AbsenceRequestFilters } from '~/modules/absence/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string } type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
@@ -198,12 +198,11 @@ import type {
AbsenceRequest, AbsenceRequest,
AbsenceStatus, AbsenceStatus,
AbsenceType, AbsenceType,
} from "~/services/dto/absence"; } from "~/modules/absence/services/dto/absence";
import { import {
useAbsenceService, useAbsenceService,
type AbsenceRequestFilters, type AbsenceRequestFilters,
} from "~/services/absences"; } from "~/modules/absence/services/absences";
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
import { useUserService } from "~/services/users"; import { useUserService } from "~/services/users";
import type { UserData } from "~/services/dto/user-data"; import type { UserData } from "~/services/dto/user-data";
@@ -6,36 +6,11 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
label="Nom" label="Nom société"
input-class="w-full" input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''" :error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true" @blur="touched.name = true"
/> />
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<MalioInputText
v-model="form.street"
label="Rue"
input-class="w-full"
/>
<MalioInputText
v-model="form.city"
label="Ville"
input-class="w-full"
/>
<MalioInputText
v-model="form.postalCode"
label="Code Postal"
input-class="w-full"
/>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<MalioButton <MalioButton
@@ -50,8 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client, ClientWrite } from '~/services/dto/client' import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/services/clients' import { useClientService } from '~/modules/directory/services/clients'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -73,37 +48,16 @@ const isSubmitting = ref(false)
const form = reactive({ const form = reactive({
name: '', name: '',
email: '',
phone: '',
street: '',
city: '',
postalCode: '',
}) })
const touched = reactive({ const touched = reactive({
name: false, name: false,
email: false,
}) })
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
if (props.client) { form.name = props.client?.name ?? ''
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
form.street = props.client.street ?? ''
form.city = props.client.city ?? ''
form.postalCode = props.client.postalCode ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
form.street = ''
form.city = ''
form.postalCode = ''
}
touched.name = false touched.name = false
touched.email = false
} }
}) })
@@ -117,11 +71,6 @@ async function handleSubmit() {
try { try {
const payload: ClientWrite = { const payload: ClientWrite = {
name: form.name.trim(), name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
street: form.street.trim() || null,
city: form.city.trim() || null,
postalCode: form.postalCode.trim() || null,
} }
if (isEditing.value && props.client) { if (isEditing.value && props.client) {
@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col gap-6 pt-6">
<!-- Formulaire d'ajout / édition -->
<div v-if="isAdmin" class="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
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>
<!-- 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" variant="ghost" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:delete-outline" variant="ghost" :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>
<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, 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 }
isAdmin: boolean
}>()
const { t } = useI18n()
const reportService = useCommercialReportService()
const documentService = useReportDocumentService()
const reports = ref<CommercialReport[]>([])
const editingId = ref<number | null>(null)
function emptyDraft(): CommercialReportWrite {
return {
subject: '',
body: null,
occurredAt: new Date().toISOString().slice(0, 10),
type: 'note',
...props.owner,
}
}
const draft = ref<CommercialReportWrite>(emptyDraft())
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)
}
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)
watch(() => props.owner, reload, { deep: true })
</script>
@@ -0,0 +1,58 @@
<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>
@@ -0,0 +1,69 @@
<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)]">
<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"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<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')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="update('postalCode', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
</div>
</template>
<script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address'
const props = defineProps<{
modelValue: Address
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Address]
'remove': []
}>()
function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
</script>
@@ -0,0 +1,73 @@
<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)]">
<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"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
@update:model-value="update('email', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
@update:model-value="update('phoneSecondary', $event)"
/>
</div>
</template>
<script setup lang="ts">
import type { Contact } from '~/modules/directory/services/dto/contact'
const props = defineProps<{
modelValue: Contact
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Contact]
'remove': []
}>()
function update(field: keyof Contact, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
</script>
@@ -0,0 +1,115 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<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="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<div class="mt-6 flex items-center justify-between gap-2">
<MalioButton
v-if="isEditing && !isConverted"
:label="$t('prospects.convert')"
variant="secondary"
icon-name="mdi:account-convert"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleConvert"
/>
<span v-else-if="isConverted" class="text-sm text-green-700">
{{ $t('prospects.alreadyConverted') }}
</span>
<span v-else />
<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 { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
const props = defineProps<{
modelValue: boolean
prospect: Prospect | 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.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false)
const form = reactive({
name: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.prospect?.name ?? ''
touched.name = false
}
})
const { create, update, convert } = useProspectService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ProspectWrite = {
name: form.name.trim(),
}
if (isEditing.value && props.prospect) {
await update(props.prospect.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleConvert() {
if (!props.prospect) return
isSubmitting.value = true
try {
await convert(props.prospect.id)
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -0,0 +1,42 @@
<template>
<ul v-if="documents.length" class="flex flex-col gap-2">
<li
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between rounded border border-neutral-200 px-3 py-2"
>
<a
:href="downloadUrl(doc.id)"
target="_blank"
rel="noopener"
class="flex items-center gap-2 text-sm text-blue-700 hover:underline"
>
<Icon name="mdi:file-document-outline" />
{{ doc.originalName }}
</a>
<MalioButtonIcon
v-if="isAdmin"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)"
/>
</li>
</ul>
<p v-else class="text-sm text-neutral-400">
{{ $t('directory.documents.empty') }}
</p>
</template>
<script setup lang="ts">
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService()
function downloadUrl(id: number): string {
return getDownloadUrl(id)
}
</script>
@@ -0,0 +1,45 @@
<template>
<div class="flex items-center gap-3">
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.documents.add')"
:disabled="uploading"
@click="fileInput?.click()"
/>
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
</div>
</template>
<script setup lang="ts">
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>()
const service = useReportDocumentService()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
async function onFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
try {
await service.upload(props.reportId, file)
emit('uploaded')
} finally {
uploading.value = false
input.value = ''
}
}
</script>
@@ -0,0 +1,121 @@
import type { Contact } from '~/modules/directory/services/dto/contact'
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 }
/**
* 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.
*/
export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService()
const addressService = useAddressService()
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 }
}
function emptyAddress(): Address {
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 {
contacts.value[index] = value
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
}
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 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)
}
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
}
}
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -0,0 +1,129 @@
<template>
<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>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="client">
<MalioTabList v-model="activeTab" :tabs="tabs">
<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" :is-admin="true" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/clients/${id}`
const owner = { client: ownerIri }
const clientService = useClientService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const client = ref<Client | null>(null)
const loading = ref(true)
const activeTab = ref('contact')
const tabs = [
{ 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' },
]
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
client.value = await clientService.getById(id)
await load()
loading.value = false
})
</script>
@@ -0,0 +1,311 @@
<template>
<div class="flex flex-col gap-6">
<h1 class="text-2xl font-bold text-neutral-900">
{{ $t('directory.title') }}
</h1>
<MalioTabList v-model="activeTab" :tabs="tabs">
<!-- Clients -->
<template #clients>
<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.clients.add')"
@click="openCreateClient"
/>
</div>
<MalioDataTable
:columns="clientColumns"
:items="clients"
:total-items="clients.length"
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
<template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }}
</template>
<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>
<!-- Prospects -->
<template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap items-end justify-between gap-3">
<MalioSelect
v-model="statusFilter"
:label="$t('prospects.fields.status')"
:options="statusOptions"
:empty-option-label="$t('directory.prospects.allStatuses')"
group-class="w-48"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prospects.add')"
@click="openCreateProspect"
/>
</div>
<MalioDataTable
:columns="prospectColumns"
:items="prospectRows"
:total-items="prospectRows.length"
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as ProspectRow).status)"
:variant="statusVariant((item as ProspectRow).status)"
/>
</template>
<template #cell-email="{ item }">
{{ (item as ProspectRow).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as ProspectRow).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end gap-2" @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>
</MalioTabList>
<ClientDrawer
v-model="clientDrawerOpen"
:client="selectedClient"
@saved="loadClients"
/>
<ProspectDrawer
v-model="prospectDrawerOpen"
:prospect="selectedProspect"
@saved="onProspectSaved"
/>
<ConfirmDeleteModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup lang="ts">
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'
definePageMeta({ middleware: ['admin'] })
type ProspectRow = Prospect
const { t } = useI18n()
useHead({ title: t('directory.title') })
const clientService = useClientService()
const prospectService = useProspectService()
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' },
]
// --- Clients ---
const clients = ref<Client[]>([])
const clientDrawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
const clientColumns = [
{ 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() {
clients.value = await clientService.getAll()
}
function openCreateClient() {
selectedClient.value = null
clientDrawerOpen.value = true
}
function openEditClient(item: Record<string, unknown>) {
navigateTo(`/directory/clients/${(item as Client).id}`)
}
// --- Prospects ---
const prospects = ref<Prospect[]>([])
const prospectDrawerOpen = ref(false)
const selectedProspect = ref<Prospect | null>(null)
const statusFilter = ref<ProspectStatus | null>(null)
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 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') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
function statusLabel(status: ProspectStatus): string {
return t(`prospects.status.${status}`)
}
function statusVariant(status: ProspectStatus): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'new':
return 'info'
case 'contacted':
return 'warning'
case 'qualified':
return 'neutral'
case 'won':
return 'success'
case 'lost':
return 'danger'
default:
return 'neutral'
}
}
async function loadProspects() {
prospects.value = await prospectService.getAll(statusFilter.value ?? undefined)
}
function openCreateProspect() {
selectedProspect.value = null
prospectDrawerOpen.value = true
}
function openEditProspect(item: Record<string, unknown>) {
navigateTo(`/directory/prospects/${(item as Prospect).id}`)
}
async function convertProspect(row: ProspectRow) {
await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()])
}
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
// donc être une conversion → toujours rafraîchir les deux listes par sécurité.
async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()])
}
// --- Suppression (clients & prospects) ---
type DeleteTarget =
| { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect }
const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() =>
deleteTarget.value?.type === 'prospect'
? t('prospects.deleteConfirmTitle')
: t('clients.deleteConfirmTitle'),
)
const deleteModalMessage = computed(() => {
if (!deleteTarget.value) return ''
const name = deleteTarget.value.item.name
return deleteTarget.value.type === 'prospect'
? t('prospects.deleteConfirmMessage', { name })
: t('clients.deleteConfirmMessage', { 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
}
async function confirmDelete() {
const target = deleteTarget.value
if (!target) return
if (target.type === 'client') {
await clientService.remove(target.item.id)
await loadClients()
} else {
await prospectService.remove(target.item.id)
await loadProspects()
}
deleteModalOpen.value = false
deleteTarget.value = null
}
watch(statusFilter, loadProspects)
onMounted(async () => {
await Promise.all([loadClients(), loadProspects()])
})
</script>
<style scoped>
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
:deep([role="tab"]) {
padding-top: 0.9rem;
padding-bottom: 0.25rem;
}
</style>
@@ -0,0 +1,129 @@
<template>
<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>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prospect">
<MalioTabList v-model="activeTab" :tabs="tabs">
<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" :is-admin="true" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Prospect } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/prospects/${id}`
const owner = { prospect: ownerIri }
const prospectService = useProspectService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const prospect = ref<Prospect | null>(null)
const loading = ref(true)
const activeTab = ref('contact')
const tabs = [
{ 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' },
]
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
prospect.value = await prospectService.getById(id)
await load()
loading.value = false
})
</script>
@@ -0,0 +1,32 @@
import type { Address, AddressWrite } from './dto/address'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string }
export function useAddressService() {
const api = useApi()
async function getByOwner(owner: Owner): Promise<Address[]> {
const data = await api.get<HydraCollection<Address>>('/addresses', owner as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: AddressWrite): Promise<Address> {
return api.post<Address>('/addresses', payload as Record<string, unknown>, {
toastSuccessKey: 'directory.addresses.saved',
})
}
async function update(id: number, payload: Partial<AddressWrite>): Promise<Address> {
return api.patch<Address>(`/addresses/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'directory.addresses.saved',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/addresses/${id}`, {}, { toastSuccessKey: 'directory.addresses.deleted' })
}
return { getByOwner, create, update, remove }
}
@@ -10,6 +10,10 @@ export function useClientService() {
return extractHydraMembers(data) return extractHydraMembers(data)
} }
async function getById(id: number): Promise<Client> {
return api.get<Client>(`/clients/${id}`)
}
async function create(payload: ClientWrite): Promise<Client> { async function create(payload: ClientWrite): Promise<Client> {
return api.post<Client>('/clients', payload as Record<string, unknown>, { return api.post<Client>('/clients', payload as Record<string, unknown>, {
toastSuccessKey: 'clients.created', toastSuccessKey: 'clients.created',
@@ -28,5 +32,5 @@ export function useClientService() {
}) })
} }
return { getAll, create, update, remove } return { getAll, getById, create, update, remove }
} }
@@ -0,0 +1,32 @@
import type { CommercialReport, CommercialReportWrite } from './dto/commercial-report'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string }
export function useCommercialReportService() {
const api = useApi()
async function getByOwner(owner: Owner): Promise<CommercialReport[]> {
const data = await api.get<HydraCollection<CommercialReport>>('/commercial_reports', owner as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: CommercialReportWrite): Promise<CommercialReport> {
return api.post<CommercialReport>('/commercial_reports', payload as Record<string, unknown>, {
toastSuccessKey: 'directory.reports.saved',
})
}
async function update(id: number, payload: Partial<CommercialReportWrite>): Promise<CommercialReport> {
return api.patch<CommercialReport>(`/commercial_reports/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'directory.reports.saved',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/commercial_reports/${id}`, {}, { toastSuccessKey: 'directory.reports.deleted' })
}
return { getByOwner, create, update, remove }
}
@@ -0,0 +1,32 @@
import type { Contact, ContactWrite } from './dto/contact'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string }
export function useContactService() {
const api = useApi()
async function getByOwner(owner: Owner): Promise<Contact[]> {
const data = await api.get<HydraCollection<Contact>>('/contacts', owner as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: ContactWrite): Promise<Contact> {
return api.post<Contact>('/contacts', payload as Record<string, unknown>, {
toastSuccessKey: 'directory.contacts.saved',
})
}
async function update(id: number, payload: Partial<ContactWrite>): Promise<Contact> {
return api.patch<Contact>(`/contacts/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'directory.contacts.saved',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/contacts/${id}`, {}, { toastSuccessKey: 'directory.contacts.deleted' })
}
return { getByOwner, create, update, remove }
}
@@ -0,0 +1,23 @@
export type Address = {
id: number
'@id'?: string
label: string | null
street: string | null
streetComplement: string | null
postalCode: string | null
city: string | null
country: string
client?: string | null
prospect?: string | null
}
export type AddressWrite = {
label: string | null
street: string | null
streetComplement: string | null
postalCode: string | null
city: string | null
country: string
client?: string | null
prospect?: string | null
}
@@ -0,0 +1,13 @@
export type Client = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
}
export type ClientWrite = {
name: string
email?: string | null
phone?: string | null
}
@@ -0,0 +1,27 @@
import type { ReportDocument } from './report-document'
export type ReportType = 'call' | 'meeting' | 'email' | 'note'
export type CommercialReport = {
id: number
'@id'?: string
subject: string
body: string | null
occurredAt: string
type: ReportType
author?: { id: number, username: string } | null
client?: string | null
prospect?: string | null
documents?: ReportDocument[]
createdAt?: string
updatedAt?: string
}
export type CommercialReportWrite = {
subject: string
body: string | null
occurredAt: string
type: ReportType
client?: string | null
prospect?: string | null
}
@@ -0,0 +1,23 @@
export type Contact = {
id: number
'@id'?: string
firstName: string | null
lastName: string | null
jobTitle: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
client?: string | null
prospect?: string | null
}
export type ContactWrite = {
firstName: string | null
lastName: string | null
jobTitle: string | null
email: string | null
phonePrimary: string | null
phoneSecondary: string | null
client?: string | null
prospect?: string | null
}
@@ -0,0 +1,28 @@
import type { Client } from './client'
export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
export type Prospect = {
id: number
'@id'?: string
name: string
company: string | null
email: string | null
phone: string | null
status: ProspectStatus
source: string | null
notes: string | null
convertedClient: Client | string | null
createdAt?: string
updatedAt?: string
}
export type ProspectWrite = {
name: string
company?: string | null
email?: string | null
phone?: string | null
status?: ProspectStatus
source?: string | null
notes?: string | null
}
@@ -0,0 +1,13 @@
import type { UserData } from '~/services/dto/user-data'
export type ReportDocument = {
'@id'?: string
id: number
commercialReport: string
originalName: string
fileName?: string | null
mimeType: string
size: number
createdAt: string
uploadedBy?: UserData | null
}
@@ -0,0 +1,44 @@
import type { Prospect, ProspectStatus, ProspectWrite } from './dto/prospect'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useProspectService() {
const api = useApi()
async function getAll(status?: ProspectStatus): Promise<Prospect[]> {
const query: Record<string, unknown> = {}
if (status) query.status = status
const data = await api.get<HydraCollection<Prospect>>('/prospects', query)
return extractHydraMembers(data)
}
async function getById(id: number): Promise<Prospect> {
return api.get<Prospect>(`/prospects/${id}`)
}
async function create(payload: ProspectWrite): Promise<Prospect> {
return api.post<Prospect>('/prospects', payload as Record<string, unknown>, {
toastSuccessKey: 'prospects.created',
})
}
async function update(id: number, payload: Partial<ProspectWrite>): Promise<Prospect> {
return api.patch<Prospect>(`/prospects/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'prospects.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/prospects/${id}`, {}, {
toastSuccessKey: 'prospects.deleted',
})
}
async function convert(id: number): Promise<Prospect> {
return api.post<Prospect>(`/prospects/${id}/convert`, {}, {
toastSuccessKey: 'prospects.converted',
})
}
return { getAll, getById, create, update, remove, convert }
}
@@ -0,0 +1,39 @@
import type { ReportDocument } from './dto/report-document'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
import { $fetch } from 'ofetch'
export function useReportDocumentService() {
const api = useApi()
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
async function getByReport(reportId: number): Promise<ReportDocument[]> {
const data = await api.get<HydraCollection<ReportDocument>>('/report_documents', {
commercialReport: `/api/commercial_reports/${reportId}`,
})
return extractHydraMembers(data)
}
async function upload(reportId: number, file: File): Promise<ReportDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('commercialReport', `/api/commercial_reports/${reportId}`)
return $fetch<ReportDocument>(`${baseURL}/report_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/report_documents/${id}`, {}, { toastSuccessKey: 'directory.documents.deleted' })
}
function getDownloadUrl(id: number): string {
return `${baseURL}/report_documents/${id}/download`
}
return { getByReport, upload, remove, getDownloadUrl }
}
@@ -1,4 +1,4 @@
import { useShareService } from '~/services/share' import { useShareService } from '~/modules/integration/services/share'
export function useShareStatus() { export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null) const enabled = useState<boolean | null>('share-enabled', () => null)
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto } from '~/services/dto/mail' import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/services/mail' import { useMailService } from '~/modules/mail/services/mail'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth' import { useAuthStore } from '~/shared/stores/auth'
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailFolderDto } from '~/services/dto/mail' import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */ /** Arbre de dossiers (getter folderTree du store) */
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/services/mail' import { useMailService } from '~/modules/mail/services/mail'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageHeaderDto } from '~/services/dto/mail' import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
messages: readonly MailMessageHeaderDto[] messages: readonly MailMessageHeaderDto[]
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail' import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml' import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/services/mail' import { useMailService } from '~/modules/mail/services/mail'
const props = defineProps<{ const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */ /** Détail complet du message. null = aucun message sélectionné. */
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMailStore } from '~/stores/mail' import { useMailStore } from '~/modules/mail/stores/mail'
const store = useMailStore() const store = useMailStore()
const { syncing } = storeToRefs(store) const { syncing } = storeToRefs(store)
+1
View File
@@ -0,0 +1 @@
export default defineNuxtConfig({})
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useMailStore } from '~/stores/mail' import { useMailStore } from '~/modules/mail/stores/mail'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
@@ -11,8 +11,8 @@ import type {
MailCreateTaskInput, MailCreateTaskInput,
MailLinkTaskInput, MailLinkTaskInput,
MailSyncResultDto, MailSyncResultDto,
} from './dto/mail' } from '~/modules/mail/services/dto/mail'
import type { Task } from './dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
type BackendMailMessage = { type BackendMailMessage = {
id: number id: number
@@ -3,8 +3,8 @@ import type {
MailFolderDto, MailFolderDto,
MailMessageHeaderDto, MailMessageHeaderDto,
MailMessageDetailDto, MailMessageDetailDto,
} from '~/services/dto/mail' } from '~/modules/mail/services/dto/mail'
import { useMailService } from '~/services/mail' import { useMailService } from '~/modules/mail/services/mail'
const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
@@ -123,13 +123,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/services/dto/project' import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Client } from '~/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import type { GiteaRepository } from '~/services/dto/gitea' import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/services/dto/bookstack' import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/modules/project-management/services/projects'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,10 +67,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups' import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -82,12 +82,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { Workflow } from '~/services/dto/workflow' import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskService } from '~/services/tasks' import { useTaskService } from '~/modules/project-management/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
defineProps<{ defineProps<{
statuses: TaskStatus[] statuses: TaskStatus[]
@@ -75,8 +75,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack' import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack' import { useBookStackService } from '~/modules/integration/services/bookstack'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
@@ -104,13 +104,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group' import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/modules/project-management/services/dto/project'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
@@ -102,7 +102,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/services/dto/task' import type { Task } from '~/modules/project-management/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -60,8 +60,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
defineProps<{ defineProps<{

Some files were not shown because too many files have changed in this diff Show More