Compare commits

..

44 Commits

Author SHA1 Message Date
Matthieu a88cb1bc35 fix(core) : harden review findings (me-provider null guard, audit-ignore plainpassword, rbac self-edit guard, module id dedup, audit pagination guard) 2026-06-19 22:39:26 +02:00
Matthieu 7686904c43 docs : log LST-61 audit log session learnings 2026-06-19 21:19:27 +02:00
Matthieu 9b26b43aca fix(core) : align audit entity-types front service with single-resource api shape 2026-06-19 21:18:22 +02:00
Matthieu e7af415a1f feat(core) : add audit log consultation tab in admin gated by permission 2026-06-19 21:15:13 +02:00
Matthieu 90b8ca15cd feat(core) : expose read-only audit-logs api with dbal provider and pagination 2026-06-19 21:09:55 +02:00
Matthieu 8c3699a9b0 feat(core) : add doctrine audit listener and mark core entities auditable 2026-06-19 21:05:34 +02:00
Matthieu d8553f06f5 feat(core) : add audit log writer and request id provider 2026-06-19 21:01:15 +02:00
Matthieu 934cf0835f feat(core) : add audit attributes, audit_log table and dedicated dbal connection 2026-06-19 20:56:32 +02:00
Matthieu fda03bd1f5 docs : add LST-61 audit log implementation plan 2026-06-19 20:53:36 +02:00
Matthieu 4760c386ed docs : log LST-57 rbac fin session learnings 2026-06-19 17:38:26 +02:00
Matthieu 511353c3f5 feat(core) : add usePermissions composable and rbac roles admin front 2026-06-19 17:35:51 +02:00
Matthieu 544d4cf44f feat(core) : gate sidebar by effective permissions 2026-06-19 17:28:42 +02:00
Matthieu 1a9eba93a0 feat(core) : add rbac seeder and seed-rbac command for system roles 2026-06-19 17:22:42 +02:00
Matthieu 48c67a5fb9 feat(core) : expose role and user-rbac api endpoints with processors 2026-06-19 17:16:38 +02:00
Matthieu 5060fb689b feat(core) : add permission voter and expose effective permissions on /api/me 2026-06-19 17:03:34 +02:00
Matthieu ac662e701b feat(core) : aggregate module permissions and add sync-permissions command 2026-06-19 17:00:14 +02:00
Matthieu ffed224979 feat(core) : add rbac role and permission entities with user relations 2026-06-19 16:56:07 +02:00
Matthieu fdc72573ea docs : add implementation plan for rbac fin (LST-57 / 1.2) 2026-06-19 16:47:04 +02:00
Matthieu 52de07ce23 docs : log LST-63 module core session learnings 2026-06-19 16:34:02 +02:00
Matthieu 117c2ff2e3 feat(core) : add core front layer with login and profile pages 2026-06-19 16:31:42 +02:00
Matthieu a98ea3df37 feat(core) : activate core module in modules registry 2026-06-19 16:27:10 +02:00
Matthieu f1a9b42930 feat(core) : move notification into core and expose notifier contract 2026-06-19 16:25:03 +02:00
Matthieu 0b4874e94d refactor(core) : move user repository/providers to core and migrate all consumers off App\Entity\User 2026-06-19 16:16:44 +02:00
Matthieu d70925b812 refactor(core) : point user relations to the shared contract via resolve_target_entities 2026-06-19 16:04:14 +02:00
Matthieu f8fc4d6bd9 feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias) 2026-06-19 16:03:52 +02:00
Matthieu 6ca91cbd3b feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract 2026-06-19 15:53:38 +02:00
Matthieu 8865bf51e6 docs : add implementation plan for module core (LST-63 / 1.1) 2026-06-19 15:50:32 +02:00
Matthieu d1a980d1c2 docs : log LST-62 socle front session learnings 2026-06-19 15:37:03 +02:00
Matthieu fdcf8df518 feat(front) : add sidebar i18n labels 2026-06-19 15:33:59 +02:00
Matthieu 977e74f669 feat(front) : render dynamic sidebar from /api/sidebar in default layout 2026-06-19 15:32:23 +02:00
Matthieu a620833550 feat(front) : load sidebar/modules after login and redirect disabled routes 2026-06-19 15:28:16 +02:00
Matthieu fcfb16fc5b docs : correct LST-62 front verification gate (typecheck is not green on this stack) 2026-06-19 15:25:39 +02:00
Matthieu b00e92bdd3 feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection 2026-06-19 15:24:57 +02:00
Matthieu 1aa43a5356 refactor(front) : move useApi and shared stores (auth, ui) to shared/ 2026-06-19 15:06:50 +02:00
Matthieu 51de96c797 feat(front) : add shared useModules/useSidebar composables and sidebar types 2026-06-19 15:05:35 +02:00
Matthieu 0ee82c8b62 feat(sidebar) : add role gate to sidebar provider and global nav config 2026-06-19 15:03:45 +02:00
Matthieu 111f37a0c9 docs : add implementation plan for socle front (LST-62 / 0.2) 2026-06-19 15:00:23 +02:00
Matthieu 5fbdda1983 docs : log LST-56 socle back session learnings 2026-06-19 15:00:17 +02:00
Matthieu b301c543bb feat(shared) : add column comments catalog helper for migrations 2026-06-19 14:38:40 +02:00
Matthieu 3053c09522 feat(shared) : add timestampable/blamable trait and doctrine subscriber 2026-06-19 14:37:28 +02:00
Matthieu 52399b35d9 feat(sidebar) : expose GET /api/sidebar filtered by active modules 2026-06-19 14:35:17 +02:00
Matthieu 748289b61a feat(modules) : expose GET /api/modules and module registry 2026-06-19 14:33:53 +02:00
Matthieu 2d0e9de155 docs : add implementation plan for socle back (LST-56 / 0.1)
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
2026-06-19 10:56:27 +02:00
Matthieu a510b2ca73 docs : add modular monolith migration roadmap and socle design
Plan de migration complet Lesstime vers modular monolith DDD (archi Starseed) : roadmap en 14 tickets ordonnés par dépendances + design technique détaillé du socle (Shared/, contrats, endpoints modules/sidebar, plan strangler).
2026-06-19 10:50:14 +02:00
516 changed files with 2593 additions and 14243 deletions
-115
View File
@@ -1,115 +0,0 @@
name: Pull Request — Quality gate
# Lance les tests back + le build front sur chaque PR ciblant develop.
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile".
on:
pull_request:
branches:
- develop
# Annule les runs obsoletes quand on repush sur la meme PR.
concurrency:
group: pr-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
backend:
name: Backend (PHP CS + PHPUnit)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
# Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le
# suffixe `_test` automatiquement en APP_ENV=test (when@test
# dbname_suffix) → la base reellement utilisee est `app_test`.
POSTGRES_USER: app
POSTGRES_PASSWORD: '!ChangeMe!'
POSTGRES_DB: app
# Pas de `ports:` host mapping : les jobs Gitea Actions tournent en
# container sur un reseau Docker dedie, le service est joignable via
# son nom (`postgres`), pas via 127.0.0.1.
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
APP_ENV: test
APP_SECRET: ci-secret-not-used
APP_DEBUG: 0
DEFAULT_URI: http://localhost/
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
JWT_PASSPHRASE: ci-passphrase
# Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que
# les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test
# alignee sur phpunit.dist.xml.
ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
# zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par
# le chiffrement des secrets, ctype/iconv par le require de composer.json.
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv
coverage: none
tools: composer:v2
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Generate JWT keypair
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
# Miroir de la cible `db-reset` du makefile (create + migrate + fixtures),
# en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ;
# sync-permissions complete le catalogue de permissions comme en install reelle.
run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
frontend:
name: Frontend (build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
# `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/).
- name: Install Node dependencies
run: npm ci
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender n'apporte rien a une quality gate — on valide seulement
# que le bundle compile.
- name: Build production (nuxt build)
run: npm run build
-6
View File
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures ## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN) - User admin : `admin` / `admin` (ROLE_ADMIN)
-14
View File
@@ -7,22 +7,8 @@ declare(strict_types=1);
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules. * Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/ */
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule; use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
use App\Module\Mail\MailModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\Reporting\ReportingModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [ return [
CoreModule::class, CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
AbsenceModule::class,
DirectoryModule::class,
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
]; ];
-14
View File
@@ -1,20 +1,6 @@
api_platform: api_platform:
title: Lesstime API title: Lesstime API
version: 1.0.0 version: 1.0.0
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
# discovered via service autoconfiguration, but #[ApiFilter] services are only
# registered for classes found in these paths — without them, every filter is
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
formats: formats:
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']
json: ['application/json'] json: ['application/json']
+6 -34
View File
@@ -22,46 +22,18 @@ 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\Module\Mail\Application\Message\MailSyncRequested': sync 'App\Message\MailSyncRequested': sync
when@test: when@test:
framework: framework:
+9 -77
View File
@@ -31,51 +31,41 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener: App\EventListener\TaskDocumentListener:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
tags: tags:
- { name: doctrine.orm.entity_listener } - { name: doctrine.orm.entity_listener }
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor: App\State\TaskDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController: App\Controller\TaskDocumentDownloadController:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool: App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool: App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\Core\Infrastructure\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController: App\Controller\Absence\AbsenceJustificationUploadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController: App\Controller\Absence\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource' App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository' App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
@@ -83,62 +73,4 @@ 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'
+5 -12
View File
@@ -10,11 +10,8 @@ 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) et user-flag * Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint. * (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
* pour éviter le doublon.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>). * Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/ */
return [ return [
@@ -23,11 +20,9 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'items' => [ 'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], ['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'], ['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'], ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'], ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
// 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'],
], ],
], ],
[ [
@@ -35,10 +30,8 @@ return [
'icon' => 'mdi:cog-outline', 'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'], 'roles' => ['ROLE_ADMIN'],
'items' => [ 'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', '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.32' app.version: '0.4.30'
+1 -31
View File
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..." echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique ./deploy.sh v0.3.13 # deploie une version specifique
``` ```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
---
## RBAC : roles & permissions (post-deploiement)
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
les peuplent, integrees au `deploy.sh` :
| Commande | Effet |
|----------|-------|
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
```bash
cd /var/www/lesstime
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
```
--- ---
@@ -1,66 +0,0 @@
# LST-58 (2.4) — Module Directory : Prospect + front répertoire (plan)
> Suite de la migration Directory. Client (back) déjà livré (`c5738d2`).
> Reste : **entité Prospect** (nouvelle) + **front répertoire** (Clients + Prospects).
> Spec produit non fournie → design défini ici de façon raisonnable, à valider au test.
> Additif, sans régression. Branche `integration/modular-monolith-0.1-1.3`.
## Design Prospect (décidé, à valider)
Aligné sur `Client` (même module Directory), enrichi des concepts de prospection commerciale.
**Entité `App\Module\Directory\Domain\Entity\Prospect`** (table `prospect`) :
- `id` int PK
- `name` string(255) NOT NULL — contact ou société
- `company` string(255) nullable
- `email` string(255) nullable
- `phone` string(50) nullable
- `street` string(255) nullable / `city` string(255) nullable / `postalCode` string(20) nullable (alignés Client)
- `status` enum `ProspectStatus` NOT NULL (default `New`)
- `source` string(255) nullable — origine (recommandation, salon, site web…)
- `notes` text nullable
- `convertedClient` ManyToOne `ClientInterface` nullable, JoinColumn ON DELETE SET NULL — rempli à la conversion
- Timestampable/Blamable (trait) + `#[Auditable]`
- Groupes : `prospect:read` / `prospect:write`
**Enum `App\Module\Directory\Domain\Enum\ProspectStatus`** : `New` (nouveau), `Contacted` (contacté), `Qualified` (qualifié), `Won` (gagné/converti), `Lost` (perdu). Méthode `label(): string` (FR), comme les autres enums.
**API Platform** (aligné Client) :
- `GetCollection` paginationEnabled:false, `is_granted('ROLE_USER')`
- `Get` ROLE_USER ; `Post`/`Patch`/`Delete` ROLE_ADMIN
- Opération custom **`Post /prospects/{id}/convert`** (processor `ConvertProspectProcessor`) : crée un `Client` à partir du Prospect (name/company→name, email, phone, adresse), lie `convertedClient`, passe `status=Won`. Sécurité ROLE_ADMIN. Renvoie le Prospect mis à jour. Idempotent si déjà converti (renvoie l'existant).
- `#[ApiFilter]` SearchFilter sur `status` (filtre répertoire).
**Repo** : `ProspectRepositoryInterface` (Domain) + `DoctrineProspectRepository` (Infra) + binding.
**MCP** (cohérent avec clients, sous `Infrastructure/Mcp/Tool/`) : `list-prospects`, `get-prospect`, `create-prospect`, `update-prospect`, `delete-prospect`, `convert-prospect`. Serializer : ajouter `prospect()` dans `src/Mcp/Tool/Serializer.php`.
**DirectoryModule.permissions()** : ajouter `directory.prospects.view`, `directory.prospects.manage` (additif).
**Migration additive** : CREATE TABLE prospect (colonnes + FK converted_client→client ON DELETE SET NULL + created_by/updated_by FK user + index + COMMENT). Down = DROP TABLE.
**Fixtures** : 2-3 prospects de démo (statuts variés), dont un converti.
## Front répertoire (`frontend/modules/directory/`)
Aujourd'hui : pas de page client dédiée (AdminClientTab + picker ProjectDrawer). On crée un vrai répertoire.
- `nuxt.config.ts` vide.
- `services/` : `clients.ts` (move depuis racine), `prospects.ts` (nouveau) + `dto/{client,prospect}.ts`.
- `pages/directory.vue` : page à 2 onglets (Clients / Prospects), tableaux paginés côté client (paginationEnabled:false back), recherche/filtre statut pour prospects.
- `components/` : `ClientDrawer.vue` (move depuis `components/client/`), `ProspectDrawer.vue` (nouveau, create/edit + bouton « Convertir en client »).
- Sidebar : ajouter item `sidebar.general.directory``/directory`, `'module' => 'directory'`, gate ROLE_ADMIN (gestion référentiel).
- Réécrire imports consommateurs de `~/services/clients` / `~/services/dto/client` (AdminClientTab, ProjectDrawer, pages projects) → `~/modules/directory/services/...`. AdminClientTab : soit le retirer de /admin au profit de /directory, soit le laisser pointer le nouveau service. Décision : garder AdminClientTab fonctionnel (repoint service) ET ajouter la page /directory (les deux coexistent ; /directory = vue dédiée).
- i18n global : ajouter clés `directory.*`, `prospects.*`, `sidebar.general.directory`.
## Vagues d'exécution
1. **Back Prospect** : enum + entité + repo + API (CRUD + convert) + MCP (6 tools) + Serializer + permissions module + fixtures + migration. Vérif cache:clear/migrate/phpunit/cs-fixer → commit.
2. **Front Directory** : layer (move client front + page répertoire + ProspectDrawer + prospects service/dto) + sidebar + imports + i18n. Vérif nuxt build → commit.
## Critères d'acceptation (ticket #58)
- [x] Clients en module (fait, c5738d2)
- [ ] Prospects en module + front répertoire fonctionnel
- [x] resolve_target_entities → Directory\Client
- [ ] make test vert, aucune migration destructive
- [ ] toggle module directory (sidebar + route /directory)
## Suite phase 2 (après 2.4)
- 2.5 (#67) Module Mail — WIP `docs/mail-integration.md`, à traiter avec précaution.
- 2.6 (#68) Module Integration (Gitea/BookStack/Zimbra/Share).
@@ -1,82 +0,0 @@
# LST-65 (2.2) — Module ProjectManagement : plan de migration
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
## Périmètre (10 entités + écosystème)
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
Enums : StatusCategory, RecurrenceType.
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
## Décisions d'architecture (figées)
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
- PAS de WorkflowInterface (Workflow est intra-module PM).
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X``App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
5. **Services CalDavService + RecurrenceCalculator**`Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
9. **resolve_target_entities** final :
```
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
```
---
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
## Tranche 2 — Move mécanique vers le module
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
## Tranche 3 — Timestampable additif (Task + Project)
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
3. Champs hors groupes API existants (le trait porte ses propres groupes).
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
## Tranche 4 — Front layer project-management
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
## Critères d'acceptation (ticket)
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
- [ ] `make test` vert, aucune migration destructive.
- [ ] Toggle module project-management (sidebar + routes) prouvé.
File diff suppressed because it is too large Load Diff
@@ -1,203 +0,0 @@
# Répertoire — Contacts, Adresses & Rapports commerciaux
**Date :** 2026-06-22
**Module :** `Directory` (Lesstime)
**Statut :** Conception validée — prêt pour plan d'implémentation
## Contexte & objectif
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
type d'échange + auteur) auquel on peut **joindre des documents**.
Décisions cadrées avec l'utilisateur :
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
## Approche retenue
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
FK** (pas de copie), ce qui préserve l'historique.
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
## Modèle de données (backend — `src/Module/Directory`)
Toutes les nouvelles entités vivent dans le module `Directory`
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
sont `#[Auditable]` comme `Client`/`Prospect`.
### `Contact` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| firstName | string? | |
| lastName | string? | |
| jobTitle | string? | fonction |
| email | string? | lowercase |
| phonePrimary | string? | |
| phoneSecondary | string? | |
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
`isContactNamed` de Starseed).
### `Address` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| label | string? | libellé libre (« Siège », « Facturation »…) |
| street | string? | |
| streetComplement | string? | |
| postalCode | string? | |
| city | string? | |
| country | string | défaut `FR` |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
### `CommercialReport` (compte-rendu, répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| subject | string | objet du compte-rendu |
| body | text | le compte-rendu lui-même |
| occurredAt | date | date de l'échange |
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
### Migration de l'adresse *inline*
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
restent sur `Client`/`Prospect` (identité principale).
### Documents des comptes-rendus
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
> recouple `ProjectManagement` ↔ `Directory`.
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
croisée `ProjectManagement``Directory`) au prix d'une duplication maîtrisée du processor
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
dépendent que du DTO document + de l'URL de download).
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
`GetCollection` filtrable par `commercialReport`.
## API Platform
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
`author` (id + username) et `documents`.
Permissions RBAC ajoutées au `Module::permissions()` :
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
`directory.clients.*` / `directory.prospects.*` existants.)
## Conversion prospect → client
`ConvertProspectProcessor`
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
automatiquement (rattachés au `CommercialReport`, pas au prospect).
## Frontend (Nuxt — `frontend/modules/directory`)
### Liste & navigation
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
### Fiches détail
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
- Bloc principal (identité : name/email/phone…), éditable en place.
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
### Services & DTO
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
## i18n
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
## Tests (PHPUnit)
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
client (`prospect = null`), idempotence.
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
ayant une adresse non vide).
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
> (relations, statuts, présence), pas des **counts absolus**.
## Hors périmètre (YAGNI)
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
- Pas de dashboard/statistiques commerciales chiffrées.
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.
+7 -12
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 '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
@@ -139,20 +139,15 @@ 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 items: section.items.map((item) => ({
.filter((item) => item.to !== '/mail') label: t(item.label),
.map((item) => ({ to: item.to,
label: t(item.label), icon: item.icon,
to: item.to, })),
icon: item.icon,
})),
})), })),
) )
@@ -19,8 +19,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence' import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -73,7 +73,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence' import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
balances: AbsenceBalance[] balances: AbsenceBalance[]
@@ -52,8 +52,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
absences: AbsenceRequest[] absences: AbsenceRequest[]
@@ -29,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { HalfDay } from '~/modules/absence/services/dto/absence' import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */ /** ISO date string "YYYY-MM-DD" or null. */
@@ -135,8 +135,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -26,8 +26,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -105,8 +105,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence' import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -51,8 +51,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence' import type { AbsencePolicy } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const service = useAbsenceService() const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([]) const rows = ref<AbsencePolicy[]>([])
@@ -51,7 +51,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService() const { getSettings, saveSettings, testConnection } = useBookStackService()
+2 -2
View File
@@ -40,8 +40,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+2 -2
View File
@@ -30,8 +30,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts' import { useTaskEffortService } from '~/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -45,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService() const { getSettings, saveSettings, testConnection } = useGiteaService()
+1 -1
View File
@@ -140,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService() const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities' import { useTaskPriorityService } from '~/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -70,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useShareSettingsService } from '~/modules/integration/services/share-settings' import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService() const { getSettings, saveSettings, testConnection } = useShareSettingsService()
+2 -2
View File
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags' import { useTaskTagService } from '~/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow } from '~/modules/project-management/services/dto/workflow' import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n() const { t } = useI18n()
+1 -1
View File
@@ -58,7 +58,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useZimbraService } from '~/modules/integration/services/zimbra' import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService() const { getSettings, saveSettings, testConnection } = useZimbraService()
+5 -5
View File
@@ -96,11 +96,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow' import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow' import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status' import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses' import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n() const { t } = useI18n()
@@ -21,6 +21,21 @@
label="Téléphone" label="Téléphone"
input-class="w-full" 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
@@ -35,8 +50,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client' import type { Client, ClientWrite } from '~/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/services/clients'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -60,6 +75,9 @@ const form = reactive({
name: '', name: '',
email: '', email: '',
phone: '', phone: '',
street: '',
city: '',
postalCode: '',
}) })
const touched = reactive({ const touched = reactive({
@@ -73,10 +91,16 @@ watch(() => props.modelValue, (open) => {
form.name = props.client.name ?? '' form.name = props.client.name ?? ''
form.email = props.client.email ?? '' form.email = props.client.email ?? ''
form.phone = props.client.phone ?? '' form.phone = props.client.phone ?? ''
form.street = props.client.street ?? ''
form.city = props.client.city ?? ''
form.postalCode = props.client.postalCode ?? ''
} else { } else {
form.name = '' form.name = ''
form.email = '' form.email = ''
form.phone = '' form.phone = ''
form.street = ''
form.city = ''
form.postalCode = ''
} }
touched.name = false touched.name = false
touched.email = false touched.email = false
@@ -95,6 +119,9 @@ async function handleSubmit() {
name: form.name.trim(), name: form.name.trim(),
email: form.email.trim() || null, email: form.email.trim() || null,
phone: form.phone.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) {
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail' import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth' import { useAuthStore } from '~/shared/stores/auth'
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailFolderDto } from '~/modules/mail/services/dto/mail' import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */ /** Arbre de dossiers (getter folderTree du store) */
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail' import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
messages: readonly MailMessageHeaderDto[] messages: readonly MailMessageHeaderDto[]
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail' import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml' import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
const props = defineProps<{ const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */ /** Détail complet du message. null = aucun message sélectionné. */
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMailStore } from '~/modules/mail/stores/mail' import { useMailStore } from '~/stores/mail'
const store = useMailStore() const store = useMailStore()
const { syncing } = storeToRefs(store) const { syncing } = storeToRefs(store)
@@ -123,13 +123,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project' import type { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea' import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack' import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,10 +67,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -82,12 +82,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/modules/project-management/services/dto/workflow' import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -167,8 +167,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FileEntry } from '~/modules/integration/services/dto/share' import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/modules/integration/services/share' import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{ defineProps<{
statuses: TaskStatus[] statuses: TaskStatus[]
@@ -75,8 +75,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack' import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
@@ -104,13 +104,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
@@ -102,7 +102,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -60,8 +60,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
defineProps<{ defineProps<{
@@ -121,8 +121,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
@@ -56,9 +56,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share' import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/modules/integration/services/share' import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -46,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{ const props = defineProps<{
taskId?: number taskId?: number
@@ -25,8 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts' import { useTaskEffortService } from '~/services/task-efforts'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -226,9 +226,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n() const { t } = useI18n()
@@ -56,10 +56,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -110,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -536,23 +536,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task' import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue' import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences' import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail' import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -569,7 +569,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'saved', task?: Task): void (e: 'saved'): void
}>() }>()
const isOpen = computed({ const isOpen = computed({
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
await removeRecurrence(props.task.recurrence.id) await removeRecurrence(props.task.recurrence.id)
} }
emit('saved', savedTask) emit('saved')
isOpen.value = false isOpen.value = false
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities' import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags' import { useTaskTagService } from '~/services/task-tags'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -64,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{ const props = defineProps<{
entry: TimeEntry entry: TimeEntry
@@ -35,7 +35,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -124,11 +124,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries' import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -150,8 +150,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const { t } = useI18n() const { t } = useI18n()
const absenceService = useAbsenceService() const absenceService = useAbsenceService()
@@ -108,9 +108,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
const props = defineProps<{ const props = defineProps<{
users: UserData[] users: UserData[]
+20 -33
View File
@@ -106,43 +106,30 @@ const touched = reactive({
password: false, password: false,
}) })
const { create, update, getById } = useUserService() watch(() => props.modelValue, (open) => {
if (open) {
function applyUser(user: UserData) { if (props.item) {
form.username = user.username ?? '' form.username = props.item.username ?? ''
form.firstName = user.firstName ?? '' form.firstName = props.item.firstName ?? ''
form.lastName = user.lastName ?? '' form.lastName = props.item.lastName ?? ''
form.password = '' form.password = ''
form.roles = [...user.roles] form.roles = [...props.item.roles]
form.isEmployee = user.isEmployee ?? false form.isEmployee = props.item.isEmployee ?? false
} } else {
form.username = ''
watch(() => props.modelValue, async (open) => { form.firstName = ''
if (!open) { form.lastName = ''
return form.password = ''
} 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.
} }
} else { touched.username = false
form.username = '' touched.password = false
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
@@ -1,4 +1,4 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger' export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
@@ -1,4 +1,4 @@
import { useShareService } from '~/modules/integration/services/share' import { useShareService } from '~/services/share'
export function useShareStatus() { export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null) const enabled = useState<boolean | null>('share-enabled', () => null)
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,160 +0,0 @@
<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>
@@ -1,69 +0,0 @@
<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>
@@ -1,73 +0,0 @@
<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>
@@ -1,191 +0,0 @@
<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="$t('prospects.fields.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.company"
:label="$t('prospects.fields.company')"
input-class="w-full"
/>
<MalioInputText
v-model="form.email"
:label="$t('prospects.fields.email')"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
:label="$t('prospects.fields.phone')"
input-class="w-full"
/>
<MalioSelect
v-model="form.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="form.source"
:label="$t('prospects.fields.source')"
input-class="w-full"
/>
<MalioInputTextArea
v-model="form.notes"
:label="$t('prospects.fields.notes')"
/>
<div class="mt-6 flex items-center justify-between gap-2">
<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, ProspectStatus, 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 { t } = useI18n()
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 statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const form = reactive<{
name: string
company: string
email: string
phone: string
status: ProspectStatus
source: string
notes: string
}>({
name: '',
company: '',
email: '',
phone: '',
status: 'new',
source: '',
notes: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.prospect) {
form.name = props.prospect.name ?? ''
form.company = props.prospect.company ?? ''
form.email = props.prospect.email ?? ''
form.phone = props.prospect.phone ?? ''
form.status = props.prospect.status ?? 'new'
form.source = props.prospect.source ?? ''
form.notes = props.prospect.notes ?? ''
} else {
form.name = ''
form.company = ''
form.email = ''
form.phone = ''
form.status = 'new'
form.source = ''
form.notes = ''
}
touched.name = false
}
})
const { create, update, convert } = useProspectService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ProspectWrite = {
name: form.name.trim(),
company: form.company.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
status: form.status,
source: form.source.trim() || null,
notes: form.notes.trim() || null,
}
if (isEditing.value && props.prospect) {
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>
@@ -1,42 +0,0 @@
<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>
@@ -1,45 +0,0 @@
<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>
@@ -1,121 +0,0 @@
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,
}
}
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,129 +0,0 @@
<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>
@@ -1,241 +0,0 @@
<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>
</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
v-if="!(item as ProspectRow).convertedClient"
class="flex justify-end"
@click.stop
>
<MalioButtonIcon
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)"
/>
</div>
<span v-else class="text-neutral-300"></span>
</template>
</MalioDataTable>
</div>
</template>
</MalioTabList>
<ClientDrawer
v-model="clientDrawerOpen"
:client="selectedClient"
@saved="loadClients"
/>
<ProspectDrawer
v-model="prospectDrawerOpen"
:prospect="selectedProspect"
@saved="onProspectSaved"
/>
</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') },
]
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()])
}
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>
@@ -1,129 +0,0 @@
<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>
@@ -1,32 +0,0 @@
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 }
}
@@ -1,32 +0,0 @@
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 }
}
@@ -1,32 +0,0 @@
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 }
}
@@ -1,23 +0,0 @@
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
}
@@ -1,27 +0,0 @@
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
}
@@ -1,23 +0,0 @@
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
}
@@ -1,28 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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
}
@@ -1,44 +0,0 @@
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 }
}
@@ -1,39 +0,0 @@
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 +0,0 @@
export default defineNuxtConfig({})
-1
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,52 +0,0 @@
<template>
<div class="h-64">
<Bar
v-if="hasData"
:data="chartData"
:options="options"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ emptyMessage ?? $t('reporting.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import { Bar } from 'vue-chartjs'
const props = defineProps<{
labels: string[]
values: number[]
color?: string
emptyMessage?: string
}>()
const hasData = computed(() => props.values.some(v => v > 0))
const chartData = computed(() => ({
labels: props.labels,
datasets: [{
data: props.values,
backgroundColor: props.color ?? '#6366f1',
borderWidth: 0,
borderRadius: 6,
}],
}))
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f3f4f6' },
},
x: {
grid: { display: false },
},
},
}
</script>
@@ -1,56 +0,0 @@
<template>
<div class="h-64">
<Doughnut
v-if="hasData"
:data="chartData"
:options="options"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ emptyMessage ?? $t('reporting.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import { Doughnut } from 'vue-chartjs'
const props = defineProps<{
labels: string[]
values: number[]
colors?: string[]
emptyMessage?: string
}>()
const palette = [
'#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6',
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#84cc16',
]
const hasData = computed(() => props.values.some(v => v > 0))
const chartData = computed(() => ({
labels: props.labels,
datasets: [{
data: props.values,
backgroundColor: props.colors ?? props.labels.map((_, i) => palette[i % palette.length]),
borderWidth: 0,
}],
}))
const options = {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
}
</script>
@@ -1,44 +0,0 @@
export type CsvColumn<T> = {
header: string
value: (row: T) => string | number
}
/** Escapes a single CSV cell (quotes wrapping + doubled inner quotes). */
function escapeCell(value: string | number): string {
const str = String(value ?? '')
if (/[",\n;]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function useCsvExport() {
/** Builds a CSV string from rows + column definitions (semicolon separator, FR-friendly). */
function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
const header = columns.map(c => escapeCell(c.header)).join(';')
const lines = rows.map(row =>
columns.map(c => escapeCell(c.value(row))).join(';'),
)
return [header, ...lines].join('\n')
}
/** Triggers a browser download of the given CSV content. */
function downloadCsv<T>(filename: string, rows: T[], columns: CsvColumn<T>[]): void {
if (typeof window === 'undefined') {
return
}
const csv = toCsv(rows, columns)
// Prepend BOM so Excel detects UTF-8.
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
return { toCsv, downloadCsv }
}

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