From 1790214ffff52b6e4e471d796ddab8b135f2b55c Mon Sep 17 00:00:00 2001 From: Sabrina Boukherouba Date: Mon, 16 Mar 2026 15:20:49 +0100 Subject: [PATCH] feat(add services on membership page) --- .claude/settings.local.json | 3 +- app/Console/Commands/SyncDolibarrMembers.php | 11 +- app/Filament/Actions/ServiceToggleAction.php | 42 ++- .../MembershipsRelationManager.php | 8 + .../Memberships/Schemas/MembershipForm.php | 269 ++++++++++++++---- app/Models/Member.php | 3 + lang/fr/memberships.php | 1 + .../App/Filament/Pages/Synchronisations.ts | 83 ++++++ .../js/actions/App/Filament/Pages/index.ts | 7 + resources/js/actions/App/Filament/index.ts | 2 + .../js/routes/filament/admin/pages/index.ts | 82 ++++++ 11 files changed, 434 insertions(+), 77 deletions(-) create mode 100644 resources/js/actions/App/Filament/Pages/Synchronisations.ts create mode 100644 resources/js/actions/App/Filament/Pages/index.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c7529f9..8dc8d9f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,8 @@ "mcp__laravel-boost__tinker", "Bash(php artisan make:job:*)", "mcp__laravel-boost__last-error", - "Bash(php artisan view:clear:*)" + "Bash(php artisan view:clear:*)", + "mcp__laravel-boost__database-query" ] }, "enableAllProjectMcpServers": true, diff --git a/app/Console/Commands/SyncDolibarrMembers.php b/app/Console/Commands/SyncDolibarrMembers.php index 4d3b514..d049b4a 100644 --- a/app/Console/Commands/SyncDolibarrMembers.php +++ b/app/Console/Commands/SyncDolibarrMembers.php @@ -8,6 +8,7 @@ use App\Services\Dolibarr\DolibarrService; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Http\Client\ConnectionException; + use function Laravel\Prompts\progress; class SyncDolibarrMembers extends Command @@ -26,6 +27,7 @@ class SyncDolibarrMembers extends Command /** * Execute the console command. + * * @throws ConnectionException */ public function handle(): void @@ -50,7 +52,7 @@ class SyncDolibarrMembers extends Command $memberStatuses = [ '-2' => 'excluded', '0' => 'cancelled', - '1' => 'valid' + '1' => 'valid', ]; foreach ($doliMembers as $member) { @@ -63,8 +65,8 @@ class SyncDolibarrMembers extends Command 'nature' => 'physical', 'member_type' => $member['type'], 'group_id' => null, - 'lastname' => $member['firstname'], - 'firstname' => $member['lastname'], + 'lastname' => $member['lastname'], + 'firstname' => $member['firstname'], 'email' => $member['email'] ?: null, 'retzien_email' => '', 'company' => $member['societe'], @@ -109,7 +111,7 @@ class SyncDolibarrMembers extends Command 'payment_status' => 'paid', 'note_public' => $membership['note_public'], 'note_private' => $membership['note_private'], - 'dolibarr_user_id' => $member['id'] + 'dolibarr_user_id' => $member['id'], ] ); @@ -139,6 +141,7 @@ class SyncDolibarrMembers extends Command /** * Convert timestamp to date format safely + * * @todo: export this in a service or repo */ private function toDate($timestamp): ?string diff --git a/app/Filament/Actions/ServiceToggleAction.php b/app/Filament/Actions/ServiceToggleAction.php index 62e3b7b..b8e9725 100644 --- a/app/Filament/Actions/ServiceToggleAction.php +++ b/app/Filament/Actions/ServiceToggleAction.php @@ -2,6 +2,7 @@ namespace App\Filament\Actions; +use App\Models\Membership; use Filament\Actions\Action; use Illuminate\Support\Facades\Bus; use App\Models\Member; @@ -10,45 +11,56 @@ class ServiceToggleAction extends Action { protected string $serviceIdentifier; + /* + * Create a new action instance. + */ public static function forService(string $serviceIdentifier): static { return static::make('toggle_' . $serviceIdentifier) ->configureForService($serviceIdentifier); } + /** + * Configure the action for a specific service. + */ protected function configureForService(string $serviceIdentifier): static { $this->serviceIdentifier = $serviceIdentifier; return $this ->label('Service actif') - ->icon(fn (Member $record) => - $record->hasService($serviceIdentifier) + ->icon(fn (Member|Membership $record) => + $this->getMember($record)?->hasService($serviceIdentifier) ? 'heroicon-o-check-circle' : 'heroicon-o-x-circle' ) - ->color(fn (Member $record) => - $record->hasService($serviceIdentifier) + ->color(fn (Member|Membership $record) => + $this->getMember($record)?->hasService($serviceIdentifier) ? 'success' : 'gray' ) ->requiresConfirmation() - ->modalHeading(fn (Member $record) => - $record->hasService($serviceIdentifier) + ->modalHeading(fn (Member|Membership $record) => + $this->getMember($record)?->hasService($serviceIdentifier) ? 'Désactiver le service' : 'Activer le service' ) - ->modalDescription(fn (Member $record) => - $record->hasService($serviceIdentifier) + ->modalDescription(fn (Member|Membership $record) => + $this->getMember($record)?->hasService($serviceIdentifier) ? 'Êtes-vous sûr·e de vouloir désactiver ce service pour ce membre ?' : 'Êtes-vous sûr·e de vouloir activer ce service pour ce membre ?' ) - ->modalSubmitActionLabel(fn (Member $record) => - $record->hasService($serviceIdentifier) + ->modalSubmitActionLabel(fn (Member|Membership $record) => + $this->getMember($record)?->hasService($serviceIdentifier) ? 'Désactiver' : 'Activer' ) - ->action(function (Member $record) use ($serviceIdentifier) { + ->action(function (Member|Membership $record) use ($serviceIdentifier) { + $member = $this->getMember($record); + + if (!$member) { + return; + } // @todo à discuter /* if ($record->hasService($serviceIdentifier)) { @@ -62,4 +74,12 @@ class ServiceToggleAction extends Action }*/ }); } + + /** + * Get the member associated with the given record. + */ + protected function getMember(Member|Membership $record): ?Member + { + return $record instanceof Member ? $record : $record->member; + } } diff --git a/app/Filament/Resources/Members/RelationManagers/MembershipsRelationManager.php b/app/Filament/Resources/Members/RelationManagers/MembershipsRelationManager.php index e1907f8..9a59c2a 100644 --- a/app/Filament/Resources/Members/RelationManagers/MembershipsRelationManager.php +++ b/app/Filament/Resources/Members/RelationManagers/MembershipsRelationManager.php @@ -11,6 +11,7 @@ use Filament\Actions\EditAction; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; class MembershipsRelationManager extends RelationManager { @@ -18,6 +19,13 @@ class MembershipsRelationManager extends RelationManager protected static ?string $title = 'Adhésions'; + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + $fullName = $ownerRecord->getFullNameAttribute(); + + return $fullName ? 'Adhésions de '.$fullName : 'Adhésions'; + } + public function table(Table $table): Table { return $table diff --git a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php index 925c19c..1a83139 100644 --- a/app/Filament/Resources/Memberships/Schemas/MembershipForm.php +++ b/app/Filament/Resources/Memberships/Schemas/MembershipForm.php @@ -2,89 +2,236 @@ namespace App\Filament\Resources\Memberships\Schemas; +use App\Enums\IspconfigType; +use App\Filament\Actions\ServiceToggleAction; use App\Models\Membership; -use App\Models\Service; use Filament\Actions\Action; -use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; -use Filament\Infolists\Infolist; +use Filament\Infolists\Components\RepeatableEntry; use Filament\Infolists\Components\TextEntry; +use Filament\Infolists\Components\ViewEntry; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; use Filament\Schemas\Schema; +use Filament\Support\Icons\Heroicon; class MembershipForm { public static function configure(Schema $schema): Schema { return $schema - ->components ([ + ->components([ Grid::make() ->schema([ + /* + |-------------------------------------------------------------------------- + | Colonne principale + |-------------------------------------------------------------------------- + */ Grid::make(1) ->schema([ - Section::make('Adhérent') - ->headerActions([ - Action::make('view-profile') - ->icon('heroicon-o-user') - ->label('Voir le profil du membre') - ->action(function (Membership $record) { - return redirect()->route('filament.admin.resources.members.edit', ['record' => $record->member_id]); - }), - ]) - ->schema([ - TextEntry::make('member.full_name') - ->label(Membership::getAttributeLabel('member_id')), - TextEntry::make('author.name') - ->label(Membership::getAttributeLabel('admin_id')), - TextEntry::make('created_at') - ->label(Membership::getAttributeLabel('created_at')) - ]) - ->columns(2), + Tabs::make('MembershipTabs') + ->tabs([ + /* + |-------------------------------------------------------------------------- + | TAB : Informations générales + |-------------------------------------------------------------------------- + */ + Tabs\Tab::make('Informations générales') + ->icon(Heroicon::OutlinedInformationCircle) + ->schema([ + Section::make('Adhérent') + ->headerActions([ + Action::make('view-profile') + ->icon('heroicon-o-user') + ->label('Voir le profil du membre') + ->action(function (Membership $record) { + return redirect()->route('filament.admin.resources.members.edit', ['record' => $record->member_id]); + }), + ]) + ->schema([ + TextEntry::make('member.full_name') + ->label(Membership::getAttributeLabel('member_id')), + TextEntry::make('author.name') + ->label(Membership::getAttributeLabel('admin_id')), + TextEntry::make('created_at') + ->label(Membership::getAttributeLabel('created_at')), + ]) + ->columns(2), - Section::make('Informations de transaction') - ->schema([ - Select::make('package_id') - ->label(Membership::getAttributeLabel('package_id')) - ->placeholder(Membership::getAttributeLabel('select_package')) - ->relationship('package', 'name') - ->required() - ->default(null), - Select::make('payment_status') - ->label(Membership::getAttributeLabel('payment_status')) - ->options(['paid' => Membership::getAttributeLabel('paid'), 'unpaid' => Membership::getAttributeLabel('unpaid'), 'partial' => Membership::getAttributeLabel('partial')]) - ->default('unpaid') - ->required(), - TextInput::make('amount') - ->label(Membership::getAttributeLabel('amount')) - ->required() - ->numeric('decimal') - ->default(0.0), - ]) - ->columns(2), + Section::make('Informations de transaction') + ->schema([ + Select::make('package_id') + ->label(Membership::getAttributeLabel('package_id')) + ->placeholder(Membership::getAttributeLabel('select_package')) + ->relationship('package', 'name') + ->required() + ->default(null), + Select::make('payment_status') + ->label(Membership::getAttributeLabel('payment_status')) + ->options(['paid' => Membership::getAttributeLabel('paid'), 'unpaid' => Membership::getAttributeLabel('unpaid'), 'partial' => Membership::getAttributeLabel('partial')]) + ->default('unpaid') + ->required(), + TextInput::make('amount') + ->label(Membership::getAttributeLabel('amount')) + ->required() + ->numeric('decimal') + ->default(0.0), + ]) + ->columns(2), + ]), - Section::make('Services') - ->schema([ - CheckboxList::make('services') - ->label('Services activés') - ->helperText('Sélectionne les services que ce membre peut utiliser.') - ->options(Service::all()->pluck('name', 'id')) - ->relationship('services', 'name') - ->columns(2) - ]) - /*->schema(function () { - return Service::all()->map(function ($service) { - return Toggle::make("services_sync.{$service->id}") - ->label($service->name) - ->default(false) - ->helperText("Active ou désactive le service {$service->name}"); - })->toArray(); - })*/ + /* + |-------------------------------------------------------------------------- + | TAB : Services/Modules + |-------------------------------------------------------------------------- + */ + Tabs\Tab::make('Modules') + ->icon(Heroicon::OutlinedPuzzlePiece) + ->schema([ + /* + | Messageries ISPConfig (lecture seule) + */ + Section::make('Messagerie ISPConfig') + ->afterHeader([ + ServiceToggleAction::forService('mail'), + ]) + ->collapsible() + ->schema([ + RepeatableEntry::make('ispconfig_mails') + ->label('Données ISPConfig Mail') + ->state(fn(?Membership $record) => $record?->member?->ispconfigs() + ->where('type', IspconfigType::MAIL) + ->get() + ) + ->schema([ + TextEntry::make('email') + ->label('Adresse email'), + TextEntry::make('ispconfig_service_user_id') + ->label('ID ISPConfig'), + + TextEntry::make('data.mailuser.quota') + ->label('Quota'), + + TextEntry::make('data.mailuser.domain') + ->label('Domaine') + ->default('retzien.fr'), + + ViewEntry::make('data') + ->label('JSON') + ->view('filament.components.json-viewer') + ->viewData(fn($state) => [ + 'data' => $state, + ]) + ->columnSpanFull(), + ]) + ->columns(2), + ]) + ->visible(fn(?Membership $record) => $record?->member?->ispconfigs() + ->where('type', IspconfigType::MAIL) + ->exists() ?? false + ), + + /* + | Hébergements web ISPConfig + */ + Section::make('Hébergements Web') + ->afterHeader([ + ServiceToggleAction::forService('webhosting'), + ]) + ->collapsible() + ->schema([ + RepeatableEntry::make('ispconfigs_web') + ->label('Données ISPConfig Web') + ->state(fn(?Membership $record) => $record?->member?->ispconfigs() + ->where('type', IspconfigType::WEB) + ->get() + ->map(fn($ispconfig) => $ispconfig->toArray()) + ->all() + ) + ->schema([ + TextEntry::make('data.domain_id') + ->label('ID ISPConfig'), + + TextEntry::make('data.domain') + ->label('Domaine'), + + TextEntry::make('data.active') + ->label('État') + ->formatStateUsing(fn($state) => $state === 'y' ? 'Activé' : 'Désactivé' + ), + + ViewEntry::make('data') + ->label('JSON') + ->view('filament.components.json-viewer') + ->viewData(fn($state) => [ + 'data' => $state, + ]) + ->columnSpanFull(), + ]) + ->columns(3), + ]) + ->visible(fn(?Membership $record) => $record?->member?->ispconfigs() + ->where('type', IspconfigType::WEB) + ->exists() ?? false + ), + + /* + | Compte(s) NextCloud (lecture seule) + */ + Section::make('NextCloud') + ->afterHeader([ + ServiceToggleAction::forService('nextcloud'), + ]) + ->collapsible() + ->schema([ + RepeatableEntry::make('nextcloud_accounts') + ->label('Données NextCloud') + ->state(fn(?Membership $record) => $record?->member?->nextcloudAccounts() + ->get() + ->map(fn($nextcloudAccount) => $nextcloudAccount->toArray()) + ->all() + ) + ->schema([ + TextEntry::make('nextcloud_user_id') + ->label('Id Nextcloud'), + + TextEntry::make('data.displayname') + ->label('Nom de l\'utilisateur'), + + TextEntry::make('data.enabled') + ->label('État') + ->formatStateUsing(fn($state) => $state == 'true' ? 'Activé' : 'Désactivé' + ), + + ViewEntry::make('data') + ->label('JSON') + ->view('filament.components.json-viewer') + ->viewData(fn($state) => [ + 'data' => $state, + ]) + ->columnSpanFull(), + ]) + ->columns(3), + ]) + ->visible(fn(?Membership $record) => $record?->member?->nextcloudAccounts() + ->exists() ?? false + ), + ]), + ]) + ->contained(false), ]) ->columnSpan(3), + + /* + |-------------------------------------------------------------------------- + | Colonne latérale + |-------------------------------------------------------------------------- + */ Grid::make(1) ->schema([ Section::make('Statut') @@ -99,13 +246,13 @@ class MembershipForm ->required(), DatePicker::make('end_date') ->label(Membership::getAttributeLabel('end_date')), - ]) + ->extraAttributes(['class' => 'sticky top-4 h-fit']), ]) ->columnSpan(1), ]) ->columns(4) - ->columnSpanFull() + ->columnSpanFull(), ]); } } diff --git a/app/Models/Member.php b/app/Models/Member.php index d159cf8..634e759 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -103,6 +103,9 @@ class Member extends Model 'phone2', 'public_membership', 'website_url', + 'member_type', + 'retzien_email', + 'created_at', ]; public static function getAttributeLabel(string $attribute): string diff --git a/lang/fr/memberships.php b/lang/fr/memberships.php index ca8a095..884bd97 100644 --- a/lang/fr/memberships.php +++ b/lang/fr/memberships.php @@ -23,6 +23,7 @@ return [ 'paid' => 'Payé', 'unpaid' => 'Impayé', 'partial' => 'Paiement partiel', + 'services' => 'Services', 'created_at' => 'Créée le', 'updated_at' => 'Mise à jour le', 'subscription' => [ diff --git a/resources/js/actions/App/Filament/Pages/Synchronisations.ts b/resources/js/actions/App/Filament/Pages/Synchronisations.ts new file mode 100644 index 0000000..2184cd2 --- /dev/null +++ b/resources/js/actions/App/Filament/Pages/Synchronisations.ts @@ -0,0 +1,83 @@ +import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../wayfinder' +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +const Synchronisations = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: Synchronisations.url(options), + method: 'get', +}) + +Synchronisations.definition = { + methods: ["get","head"], + url: '/admin/synchronisations', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +Synchronisations.url = (options?: RouteQueryOptions) => { + return Synchronisations.definition.url + queryParams(options) +} + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +Synchronisations.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: Synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +Synchronisations.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: Synchronisations.url(options), + method: 'head', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +const SynchronisationsForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: Synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +SynchronisationsForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: Synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +SynchronisationsForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: Synchronisations.url({ + [options?.mergeQuery ? 'mergeQuery' : 'query']: { + _method: 'HEAD', + ...(options?.query ?? options?.mergeQuery ?? {}), + } + }), + method: 'get', +}) + +Synchronisations.form = SynchronisationsForm + +export default Synchronisations \ No newline at end of file diff --git a/resources/js/actions/App/Filament/Pages/index.ts b/resources/js/actions/App/Filament/Pages/index.ts new file mode 100644 index 0000000..d1219c1 --- /dev/null +++ b/resources/js/actions/App/Filament/Pages/index.ts @@ -0,0 +1,7 @@ +import Synchronisations from './Synchronisations' + +const Pages = { + Synchronisations: Object.assign(Synchronisations, Synchronisations), +} + +export default Pages \ No newline at end of file diff --git a/resources/js/actions/App/Filament/index.ts b/resources/js/actions/App/Filament/index.ts index 7749857..93c2cb4 100644 --- a/resources/js/actions/App/Filament/index.ts +++ b/resources/js/actions/App/Filament/index.ts @@ -1,6 +1,8 @@ +import Pages from './Pages' import Resources from './Resources' const Filament = { + Pages: Object.assign(Pages, Pages), Resources: Object.assign(Resources, Resources), } diff --git a/resources/js/routes/filament/admin/pages/index.ts b/resources/js/routes/filament/admin/pages/index.ts index 7273b84..052825a 100644 --- a/resources/js/routes/filament/admin/pages/index.ts +++ b/resources/js/routes/filament/admin/pages/index.ts @@ -80,8 +80,90 @@ dashboardForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> = dashboard.form = dashboardForm +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +export const synchronisations = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: synchronisations.url(options), + method: 'get', +}) + +synchronisations.definition = { + methods: ["get","head"], + url: '/admin/synchronisations', +} satisfies RouteDefinition<["get","head"]> + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +synchronisations.url = (options?: RouteQueryOptions) => { + return synchronisations.definition.url + queryParams(options) +} + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +synchronisations.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({ + url: synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +synchronisations.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({ + url: synchronisations.url(options), + method: 'head', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +const synchronisationsForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +synchronisationsForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: synchronisations.url(options), + method: 'get', +}) + +/** +* @see \App\Filament\Pages\Synchronisations::__invoke +* @see app/Filament/Pages/Synchronisations.php:7 +* @route '/admin/synchronisations' +*/ +synchronisationsForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({ + action: synchronisations.url({ + [options?.mergeQuery ? 'mergeQuery' : 'query']: { + _method: 'HEAD', + ...(options?.query ?? options?.mergeQuery ?? {}), + } + }), + method: 'get', +}) + +synchronisations.form = synchronisationsForm + const pages = { dashboard: Object.assign(dashboard, dashboard), + synchronisations: Object.assign(synchronisations, synchronisations), } export default pages \ No newline at end of file