Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a88cb1bc35 | |||
| 7686904c43 | |||
| 9b26b43aca | |||
| e7af415a1f | |||
| 90b8ca15cd | |||
| 8c3699a9b0 | |||
| d8553f06f5 | |||
| 934cf0835f | |||
| fda03bd1f5 | |||
| 4760c386ed | |||
| 511353c3f5 | |||
| 544d4cf44f | |||
| 1a9eba93a0 | |||
| 48c67a5fb9 | |||
| 5060fb689b | |||
| ac662e701b | |||
| ffed224979 | |||
| fdc72573ea | |||
| 52de07ce23 | |||
| 117c2ff2e3 | |||
| a98ea3df37 | |||
| f1a9b42930 | |||
| 0b4874e94d | |||
| d70925b812 | |||
| f8fc4d6bd9 | |||
| 6ca91cbd3b | |||
| 8865bf51e6 | |||
| d1a980d1c2 | |||
| fdcf8df518 | |||
| 977e74f669 | |||
| a620833550 | |||
| fcfb16fc5b | |||
| b00e92bdd3 | |||
| 1aa43a5356 | |||
| 51de96c797 | |||
| 0ee82c8b62 | |||
| 111f37a0c9 | |||
| 5fbdda1983 | |||
| b301c543bb | |||
| 3053c09522 | |||
| 52399b35d9 | |||
| 748289b61a | |||
| 2d0e9de155 | |||
| a510b2ca73 |
@@ -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
|
||||
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
||||
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
- Après modif nginx : `docker restart nginx-lesstime`
|
||||
|
||||
## Déploiement (prod Docker)
|
||||
|
||||
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
|
||||
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
|
||||
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
|
||||
|
||||
## Fixtures
|
||||
|
||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||
|
||||
@@ -7,22 +7,8 @@ declare(strict_types=1);
|
||||
* 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\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 [
|
||||
CoreModule::class,
|
||||
TimeTrackingModule::class,
|
||||
ProjectManagementModule::class,
|
||||
AbsenceModule::class,
|
||||
DirectoryModule::class,
|
||||
MailModule::class,
|
||||
IntegrationModule::class,
|
||||
ReportingModule::class,
|
||||
];
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
api_platform:
|
||||
title: Lesstime API
|
||||
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:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -22,46 +22,18 @@ doctrine:
|
||||
auto_mapping: true
|
||||
resolve_target_entities:
|
||||
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:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/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:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ framework:
|
||||
# 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
|
||||
# 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:
|
||||
framework:
|
||||
|
||||
+9
-77
@@ -31,51 +31,41 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
|
||||
App\EventListener\TaskDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
|
||||
App\State\TaskDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
App\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\Core\Infrastructure\Controller\UserAvatarController:
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
|
||||
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
|
||||
|
||||
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
|
||||
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||
|
||||
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\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'
|
||||
|
||||
+5
-12
@@ -10,11 +10,8 @@ declare(strict_types=1);
|
||||
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
|
||||
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
|
||||
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||
* (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>).
|
||||
*/
|
||||
return [
|
||||
@@ -23,11 +20,9 @@ return [
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
|
||||
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
|
||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -35,10 +30,8 @@ return [
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.31'
|
||||
app.version: '0.4.30'
|
||||
|
||||
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Seeding RBAC system roles (idempotent)..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
||||
|
||||
echo "==> Syncing RBAC permissions catalog..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
@@ -300,31 +294,7 @@ cd /var/www/lesstime
|
||||
./deploy.sh v0.3.13 # deploie une version specifique
|
||||
```
|
||||
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
|
||||
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
## RBAC : roles & permissions (post-deploiement)
|
||||
|
||||
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
|
||||
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
|
||||
les peuplent, integrees au `deploy.sh` :
|
||||
|
||||
| Commande | Effet |
|
||||
|----------|-------|
|
||||
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
|
||||
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
|
||||
|
||||
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
|
||||
|
||||
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
||||
```
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,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.
|
||||
@@ -125,8 +125,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
@@ -139,20 +139,15 @@ const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
// `/mail` est déclaré dans config/sidebar.php pour le gating module (disabledRoutes),
|
||||
// mais son rendu visuel + badge non-lus est géré manuellement ci-dessous (feature-flag Mail).
|
||||
// On le filtre des sections dynamiques pour éviter un doublon dans la nav.
|
||||
const translatedSections = computed(() =>
|
||||
sections.value.map((section) => ({
|
||||
label: t(section.label),
|
||||
icon: section.icon,
|
||||
items: section.items
|
||||
.filter((item) => item.to !== '/mail')
|
||||
.map((item) => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
})),
|
||||
items: section.items.map((item) => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-1
@@ -73,7 +73,8 @@
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
balances: AbsenceBalance[]
|
||||
+3
-2
@@ -52,8 +52,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
+3
-2
@@ -135,8 +135,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-2
@@ -26,8 +26,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-2
@@ -105,8 +105,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -51,8 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
|
||||
import { useShareSettingsService } from '~/services/share-settings'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useZimbraService } from '~/modules/integration/services/zimbra'
|
||||
import { useZimbraService } from '~/services/zimbra'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||
|
||||
|
||||
@@ -96,11 +96,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
+29
-2
@@ -21,6 +21,21 @@
|
||||
label="Téléphone"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.street"
|
||||
label="Rue"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.city"
|
||||
label="Ville"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.postalCode"
|
||||
label="Code Postal"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
@@ -35,8 +50,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -60,6 +75,9 @@ const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -73,10 +91,16 @@ watch(() => props.modelValue, (open) => {
|
||||
form.name = props.client.name ?? ''
|
||||
form.email = props.client.email ?? ''
|
||||
form.phone = props.client.phone ?? ''
|
||||
form.street = props.client.street ?? ''
|
||||
form.city = props.client.city ?? ''
|
||||
form.postalCode = props.client.postalCode ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.street = ''
|
||||
form.city = ''
|
||||
form.postalCode = ''
|
||||
}
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
@@ -95,6 +119,9 @@ async function handleSubmit() {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
street: form.street.trim() || null,
|
||||
city: form.city.trim() || null,
|
||||
postalCode: form.postalCode.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.client) {
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<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 { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/modules/mail/stores/mail'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
+7
-7
@@ -123,13 +123,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+4
-4
@@ -67,10 +67,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+6
-6
@@ -82,12 +82,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -167,8 +167,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import type { FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
+2
-2
@@ -75,8 +75,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
+6
-6
@@ -104,13 +104,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
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<{
|
||||
selectedCount: number
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
task: Task
|
||||
+2
-2
@@ -60,8 +60,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
defineProps<{
|
||||
+2
-2
@@ -121,8 +121,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+1
-1
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-3
@@ -226,9 +226,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
+4
-4
@@ -56,10 +56,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
task: Task
|
||||
+16
-16
@@ -536,23 +536,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -569,7 +569,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved', task?: Task): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
|
||||
await removeRecurrence(props.task.recurrence.id)
|
||||
}
|
||||
|
||||
emit('saved', savedTask)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
+2
-2
@@ -28,8 +28,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-2
@@ -28,8 +28,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
entry: TimeEntry
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
visible: boolean
|
||||
+4
-4
@@ -124,11 +124,11 @@
|
||||
</template>
|
||||
|
||||
<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 { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const props = defineProps<{
|
||||
+2
-2
@@ -150,8 +150,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
+3
-3
@@ -108,9 +108,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
|
||||
const props = defineProps<{
|
||||
users: UserData[]
|
||||
@@ -106,43 +106,30 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
const { create, update, getById } = useUserService()
|
||||
|
||||
function applyUser(user: UserData) {
|
||||
form.username = user.username ?? ''
|
||||
form.firstName = user.firstName ?? ''
|
||||
form.lastName = user.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...user.roles]
|
||||
form.isEmployee = user.isEmployee ?? false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
if (props.item) {
|
||||
applyUser(props.item)
|
||||
try {
|
||||
const full = await getById(props.item.id)
|
||||
applyUser(full)
|
||||
} catch {
|
||||
// Keep the list data if the detailed fetch fails.
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.firstName = props.item.firstName ?? ''
|
||||
form.lastName = props.item.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
}
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useUserService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.username = true
|
||||
touched.password = true
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
|
||||
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
|
||||
export function useShareStatus() {
|
||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||
+838
-990
File diff suppressed because it is too large
Load Diff
@@ -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 +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
Reference in New Issue
Block a user