diff --git a/app/Concerns/HasUserStatus.php b/app/Concerns/HasUserStatus.php index b327606e..3f07b4fe 100644 --- a/app/Concerns/HasUserStatus.php +++ b/app/Concerns/HasUserStatus.php @@ -18,6 +18,11 @@ public function isPending(): bool return UserStatus::isValue($this->status, UserStatus::PENDING); } + public function isInactive(): bool + { + return UserStatus::isValue($this->status, UserStatus::INACTIVE); + } + public function setPendingStatus(): void { $this->update(['status' => UserStatus::PENDING]); @@ -27,4 +32,9 @@ public function deactivate(): void { $this->update(['status' => UserStatus::INACTIVE]); } + + public function activate(): void + { + $this->update(['status' => UserStatus::ACTIVE]); + } } diff --git a/app/Enums/InstitutionStatus.php b/app/Enums/InstitutionStatus.php new file mode 100644 index 00000000..6377d446 --- /dev/null +++ b/app/Enums/InstitutionStatus.php @@ -0,0 +1,37 @@ + Color::Green, + self::INACTIVE => Color::Red, + self::PENDING => Color::Yellow, + }; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource.php b/app/Filament/Admin/Resources/InstitutionResource.php new file mode 100644 index 00000000..9ee86258 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource.php @@ -0,0 +1,83 @@ +schema([ + // + ]); + } + + public static function table(Table $table): Table + { + return $table + ->modifyQueryUsing( + fn (Builder $query) => $query + ->withCount(['organizations', 'beneficiaries', 'users']) + ->with(['county', 'city']) + ) + ->columns([ + TextColumn::make('name') + ->label(__('institution.headings.institution_name')), + + TextColumn::make('county_and_city') + ->label(__('institution.headings.registered_office')), + + TextColumn::make('organizations_count') + ->label(__('institution.headings.centers')), + + TextColumn::make('beneficiaries_count') + ->label(__('institution.headings.cases')), + + TextColumn::make('users_count') + ->label(__('institution.headings.specialists')), + + TextColumn::make('status') + ->label(__('institution.headings.status')), + ]) + ->filters([ + // + ]) + ->actions([ + ViewAction::make() + ->label(__('general.action.view_details')), + ]) + ->emptyStateIcon('heroicon-o-clipboard-document-list') + ->emptyStateHeading(__('institution.headings.empty_state')) + ->emptyStateDescription(null); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInstitutions::route('/'), + 'create' => Pages\CreateInstitution::route('/create'), + 'view' => Pages\ViewInstitution::route('/{record}'), + 'edit_institution_details' => Pages\EditInstitutionDetails::route('/{record}/editInstitutionDetails'), + 'edit_institution_centers' => Pages\EditInstitutionCenters::route('/{record}/editCenters'), + 'user.view' => ViewUserInstitution::route('{parent}/user/{record}'), + 'user.edit' => EditUserInstitution::route('{parent}/user/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Actions/ActivateInstitution.php b/app/Filament/Admin/Resources/InstitutionResource/Actions/ActivateInstitution.php new file mode 100644 index 00000000..1ec7eeb8 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Actions/ActivateInstitution.php @@ -0,0 +1,24 @@ +name('activate_institution'); + $this->label(__('institution.actions.activate')); + $this->icon('heroicon-s-arrow-path'); + $this->color('success'); + $this->outlined(); + $this->visible(fn (Institution $record) => $record->isInactivated()); + $this->action(fn (Institution $record) => $record->activate()); + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Actions/InactivateInstitution.php b/app/Filament/Admin/Resources/InstitutionResource/Actions/InactivateInstitution.php new file mode 100644 index 00000000..450538f9 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Actions/InactivateInstitution.php @@ -0,0 +1,27 @@ +name('inactivate_institution'); + $this->label(__('institution.actions.inactivate')); + $this->icon('heroicon-o-user-minus'); + $this->color('danger'); + $this->outlined(); + $this->visible(fn (Institution $record) => $record->isActivated()); + $this->modalHeading(__('institution.headings.inactivate')); + $this->modalDescription(__('institution.labels.inactivate')); + $this->modalSubmitActionLabel(__('institution.actions.inactivate')); + $this->action(fn (Institution $record) => $record->inactivate()); + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Pages/CreateInstitution.php b/app/Filament/Admin/Resources/InstitutionResource/Pages/CreateInstitution.php new file mode 100644 index 00000000..2e349493 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Pages/CreateInstitution.php @@ -0,0 +1,71 @@ +schema(EditInstitutionDetails::getSchema()), + + Step::make(__('institution.headings.center_details')) + ->schema([ + Placeholder::make('center_details') + ->hiddenLabel() + ->maxWidth('3xl') + ->content(__('institution.placeholders.center_details')), + + ...EditInstitutionCenters::getSchema(), + ]), + + Step::make(__('institution.headings.ngo_admin')) + ->schema([ + Placeholder::make('ngo_admins') + ->hiddenLabel() + ->maxWidth('3xl') + ->content(__('institution.placeholders.ngo_admins')), + + Repeater::make('admins') + ->maxWidth('3xl') + ->hiddenLabel() + ->columns() + ->minItems(1) + ->relationship('admins') + ->addActionLabel(__('institution.actions.add_admin')) + ->schema([ + ...EditUserInstitution::getSchema(), + + Hidden::make('ngo_admin') + ->default(1), + ]), + ]), + ]; + } + + public function afterCreate() + { + $record = $this->getRecord(); + $admins = $record->admins; + $organizations = $record->organizations; + + $organizations->each(fn (Organization $organization) => $organization->users()->attach($admins->pluck('id'))); + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionCenters.php b/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionCenters.php new file mode 100644 index 00000000..edba27a0 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionCenters.php @@ -0,0 +1,86 @@ + $this->getRecord(), + 'activeRelationManager' => 'organizations', + ]); + } + + public function getBreadcrumbs(): array + { + return [ + InstitutionResource::getUrl() => __('institution.headings.list_title'), + InstitutionResource::getUrl('view', ['record' => $this->getRecord()]) => $this->getRecord()->name, + ]; + } + + public function getTitle(): string|Htmlable + { + return $this->getRecord()->name; + } + + public function form(Form $form): Form + { + return $form->schema(self::getSchema()); + } + + public static function getSchema(): array + { + return [ + Repeater::make('organizations') + ->maxWidth('3xl') + ->hiddenLabel() + ->columns() + ->minItems(1) + ->relationship('organizations') + ->addActionLabel(__('institution.actions.add_organization')) + ->schema([ + TextInput::make('name') + ->label(__('institution.labels.center_name')), + + TextInput::make('short_name') + ->label(__('organization.field.short_name')), + + TextInput::make('main_activity') + ->label(__('organization.field.main_activity')) + ->columnSpanFull(), + + SpatieMediaLibraryFileUpload::make('social_service_licensing_certificate') + ->label(__('institution.labels.social_service_licensing_certificate')) + ->helperText(__('institution.helper_texts.social_service_licensing_certificate')) + ->collection('social_service_licensing_certificate') + ->columnSpanFull(), + + SpatieMediaLibraryFileUpload::make('logo') + ->label(__('institution.labels.logo_center')) + ->helperText(__('institution.helper_texts.logo')) + ->collection('logo') + ->columnSpanFull(), + + SpatieMediaLibraryFileUpload::make('organization_header') + ->label(__('institution.labels.organization_header')) + ->helperText(__('institution.helper_texts.organization_header')) + ->collection('organization_header') + ->columnSpanFull(), + ]), + ]; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionDetails.php b/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionDetails.php new file mode 100644 index 00000000..5e6201cb --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Pages/EditInstitutionDetails.php @@ -0,0 +1,108 @@ + $this->getRecord()]); + } + + public function getBreadcrumbs(): array + { + return [ + InstitutionResource::getUrl() => __('institution.headings.list_title'), + InstitutionResource::getUrl('view', ['record' => $this->getRecord()]) => $this->getRecord()->name, + ]; + } + + public function getTitle(): string|Htmlable + { + return $this->getRecord()->name; + } + + public function form(Form $form): Form + { + return $form->schema(self::getSchema()); + } + + public static function getSchema(): array + { + return [ + Section::make() + ->maxWidth('3xl') + ->columns() + ->schema([ + TextInput::make('name') + ->label(__('organization.field.name')), + + TextInput::make('short_name') + ->label(__('organization.field.short_name')), + + Select::make('type') + ->label(__('organization.field.type')) + ->options(OrganizationType::options()) + ->enum(OrganizationType::class), + + TextInput::make('cif') + ->label(__('organization.field.cif')) + ->rule(new ValidCIF), + + TextInput::make('main_activity') + ->label(__('organization.field.main_activity')), + + Location::make() + ->city() + ->required(), + + TextInput::make('address') + ->label(__('organization.field.address')) + ->maxLength(200) + ->required(), + + TextInput::make('phone') + ->label(__('organization.field.phone')) + ->tel(), + + TextInput::make('reprezentative_name') + ->label(__('organization.field.reprezentative_name')), + + TextInput::make('reprezentative_email') + ->label(__('organization.field.reprezentative_email')), + + TextInput::make('website') + ->label(__('organization.field.website')) + ->url(), + + SpatieMediaLibraryFileUpload::make('organization_status') + ->label(__('institution.labels.organization_status')) + ->helperText(__('institution.helper_texts.organization_status')) + ->collection('organization_status') + ->columnSpanFull(), + + SpatieMediaLibraryFileUpload::make('social_service_provider_certificate') + ->label(__('institution.labels.social_service_provider_certificate')) + ->helperText(__('institution.helper_texts.social_service_provider_certificate')) + ->collection('social_service_provider_certificate') + ->columnSpanFull(), + ]), + ]; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Pages/ListInstitutions.php b/app/Filament/Admin/Resources/InstitutionResource/Pages/ListInstitutions.php new file mode 100644 index 00000000..899e7b0b --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Pages/ListInstitutions.php @@ -0,0 +1,33 @@ +label(__('institution.actions.create')), + ]; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/Pages/ViewInstitution.php b/app/Filament/Admin/Resources/InstitutionResource/Pages/ViewInstitution.php new file mode 100644 index 00000000..d14568b7 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/Pages/ViewInstitution.php @@ -0,0 +1,124 @@ + __('institution.headings.list_title'), + InstitutionResource::getUrl('view', ['record' => $this->getRecord()]) => $this->getRecord()->name, + ]; + } + + public function getTitle(): string|Htmlable + { + return $this->getRecord()->name; + } + + protected function getActions(): array + { + return [ + ActivateInstitution::make(), + + InactivateInstitution::make(), + ]; + } + + public function infolist(Infolist $infolist): Infolist + { + return $infolist->schema([ + Section::make(__('institution.headings.institution_details')) + ->headerActions([ + Action::make('edit') + ->label(__('general.action.edit')) + ->icon('heroicon-o-pencil') + ->link() + ->url(self::$resource::getUrl('edit_institution_details', ['record' => $this->getRecord()])), + ]) + ->maxWidth('3xl') + ->columns() + ->schema($this->getInfolistSchema()), + ]); + } + + public static function getInfolistSchema(): array + { + return [ + TextEntry::make('name') + ->label(__('organization.field.name')), + + TextEntry::make('short_name') + ->label(__('organization.field.short_name')), + + TextEntry::make('type') + ->label(__('organization.field.type')), + + TextEntry::make('cif') + ->label(__('organization.field.cif')), + + TextEntry::make('main_activity') + ->label(__('organization.field.main_activity')), + + Location::make() + ->city(), + + TextEntry::make('address') + ->label(__('organization.field.address')), + + TextEntry::make('phone') + ->label(__('organization.field.phone')), + + TextEntry::make('reprezentative_name') + ->label(__('organization.field.reprezentative_name')), + + TextEntry::make('reprezentative_email') + ->label(__('organization.field.reprezentative_email')), + + TextEntry::make('website') + ->label(__('organization.field.website')), + + TextEntry::make('organization_status') + ->label(__('institution.labels.organization_status')) + ->columnSpanFull(), + + TextEntry::make('social_service_provider_certificate') + ->label(__('institution.labels.social_service_provider_certificate')) + ->columnSpanFull(), + ]; + } + + public function hasCombinedRelationManagerTabsWithContent(): bool + { + return true; + } + + public function getContentTabLabel(): ?string + { + return __('institution.headings.institution_details'); + } + + public function getRelationManagers(): array + { + return [ + 'organizations' => InstitutionResource\RelationManagers\OrganizationsRelationManager::make(), + 'admins' => InstitutionResource\RelationManagers\AdminsRelationManager::make(), + ]; + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/AdminsRelationManager.php b/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/AdminsRelationManager.php new file mode 100644 index 00000000..6565ef96 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/AdminsRelationManager.php @@ -0,0 +1,90 @@ +schema([ + TextInput::make('first_name') + ->label(__('institution.labels.first_name')), + + TextInput::make('last_name') + ->label(__('institution.labels.last_name')), + + TextInput::make('email') + ->label(__('institution.labels.email')), + + TextInput::make('phone') + ->label(__('institution.labels.phone')), + + Hidden::make('ngo_admin') + ->default(1), + ]); + } + + public function table(Table $table): Table + { + return $table + ->heading(__('institution.headings.admin_users')) + ->columns([ + TextColumn::make('first_name') + ->label(__('institution.labels.first_name')), + + TextColumn::make('last_name') + ->label(__('institution.labels.last_name')), + + TextColumn::make('roles') + ->label(__('institution.labels.roles')), + + TextColumn::make('status') + ->label(__('institution.labels.account_status')), + + TextColumn::make('last_login_at') + ->label(__('institution.labels.last_login_at')), + ]) + ->headerActions([ + CreateAction::make() + ->label(__('institution.actions.add_ngo_admin')) + ->modalHeading(__('institution.actions.add_ngo_admin')) + ->createAnother(false), + ]) + ->actions([ + ViewAction::make() + ->label(__('general.action.view_details')) + ->url( + fn ($record) => InstitutionResource::getUrl('user.view', [ + 'parent' => $this->getOwnerRecord(), + 'record' => $record, + ]) + ), + ]); + } + + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('institution.headings.ngo_admin'); + } +} diff --git a/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/OrganizationsRelationManager.php b/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/OrganizationsRelationManager.php new file mode 100644 index 00000000..4e5e3775 --- /dev/null +++ b/app/Filament/Admin/Resources/InstitutionResource/RelationManagers/OrganizationsRelationManager.php @@ -0,0 +1,79 @@ +schema([ + Section::make() + ->maxWidth('3xl') + ->schema([ + SectionHeader::make('center_details') + ->state(__('institution.headings.center_details')) + ->action( + Action::make('edit_centers') + ->label(__('general.action.edit')) + ->icon('heroicon-o-pencil') + ->link() + ->url(InstitutionResource::getUrl('edit_institution_centers', ['record' => $this->getOwnerRecord()])) + ), + + RepeatableEntry::make('organizations') + ->hiddenLabel() + ->columns() + ->schema($this->getOrganizationInfolistSchema()), + ]), + + ])->state(['organizations' => $this->getOwnerRecord()->organizations->toArray()]); + } + + public static function getOrganizationInfolistSchema(): array + { + return [ + TextEntry::make('name') + ->label(__('institution.labels.center_name')), + + TextEntry::make('short_name') + ->label(__('organization.field.short_name')), + + TextEntry::make('main_activity') + ->label(__('organization.field.main_activity')) + ->columnSpanFull(), + + TextEntry::make('social_service_licensing_certificate') + ->label(__('institution.labels.social_service_licensing_certificate')) + ->columnSpanFull(), + + TextEntry::make('logo') + ->label(__('institution.labels.logo_center')) + ->columnSpanFull(), + + TextEntry::make('organization_header') + ->label(__('institution.labels.organization_header')) + ->columnSpanFull(), + ]; + } +} diff --git a/app/Filament/Admin/Resources/OrganizationResource.php b/app/Filament/Admin/Resources/OrganizationResource.php index 0d200b21..0d370227 100644 --- a/app/Filament/Admin/Resources/OrganizationResource.php +++ b/app/Filament/Admin/Resources/OrganizationResource.php @@ -22,12 +22,15 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +// TODO: remove this class OrganizationResource extends Resource { protected static ?string $model = Organization::class; protected static ?string $navigationIcon = 'heroicon-o-building-office-2'; + protected static bool $shouldRegisterNavigation = false; + public static function infolist(Infolist $infolist): Infolist { return $infolist diff --git a/app/Filament/Admin/Resources/UserInstitutionResource.php b/app/Filament/Admin/Resources/UserInstitutionResource.php new file mode 100644 index 00000000..ef1ee5ba --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource.php @@ -0,0 +1,27 @@ + Pages\ListUserInstitutions::route('/'), + ]; + } +} diff --git a/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ActivateUserAction.php b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ActivateUserAction.php new file mode 100644 index 00000000..3c31a9a7 --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ActivateUserAction.php @@ -0,0 +1,39 @@ +visible(fn (User $record) => $record->isInactive()); + + $this->label(__('user.actions.activate')); + + $this->color('success'); + + $this->outlined(); + + $this->icon('heroicon-o-arrow-path'); + + $this->modalWidth('md'); + + $this->action(function (User $record) { + $record->activate(); + $this->success(); + }); + + } +} diff --git a/app/Filament/Admin/Resources/UserInstitutionResource/Actions/DeactivateUserAction.php b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/DeactivateUserAction.php new file mode 100644 index 00000000..f0c620f8 --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/DeactivateUserAction.php @@ -0,0 +1,42 @@ +visible(fn (User $record) => $record->isActive()); + + $this->label(__('user.actions.deactivate')); + + $this->color('danger'); + + $this->outlined(); + + $this->icon('heroicon-o-user-minus'); + + $this->modalHeading(__('user.action_deactivate_confirm.title')); + + $this->modalWidth('md'); + + $this->action(function (User $record) { + $record->deactivate(); + $this->success(); + }); + + $this->successNotificationTitle(__('user.action_deactivate_confirm.success')); + } +} diff --git a/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ResendInvitationAction.php b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ResendInvitationAction.php new file mode 100644 index 00000000..08a13480 --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource/Actions/ResendInvitationAction.php @@ -0,0 +1,61 @@ +visible(fn (User $record) => $record->isPending()); + + $this->label(__('user.actions.resend_invitation')); + + $this->icon('heroicon-o-envelope-open'); + + $this->modalHeading(__('user.action_resend_invitation_confirm.title')); + + $this->modalWidth('md'); + + $this->action(function (User $record) { + $key = $this->getRateLimiterKey($record); + $maxAttempts = 1; + + if (RateLimiter::tooManyAttempts($key, $maxAttempts)) { + return $this->failure(); + } + + RateLimiter::increment($key, HOUR_IN_SECONDS); + + $record->sendWelcomeNotification(); + $this->success(); + }); + + $this->successNotificationTitle(__('user.action_resend_invitation_confirm.success')); + + $this->failureNotification( + fn (Notification $notification) => $notification + ->danger() + ->title(__('user.action_resend_invitation_confirm.failure_title')) + ->body(__('user.action_resend_invitation_confirm.failure_body')) + ); + } + + private function getRateLimiterKey(User $user): string + { + return 'resend-invitation:' . $user->id; + } +} diff --git a/app/Filament/Admin/Resources/UserInstitutionResource/Pages/EditUserInstitution.php b/app/Filament/Admin/Resources/UserInstitutionResource/Pages/EditUserInstitution.php new file mode 100644 index 00000000..43fad57d --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource/Pages/EditUserInstitution.php @@ -0,0 +1,73 @@ + $this->parent, + 'activeRelationManager' => 'admins', + ]); + } + + public function getBreadcrumbs(): array + { + return [ + InstitutionResource::getUrl() => __('institution.headings.list_title'), + InstitutionResource::getUrl('view', ['record' => $this->parent]) => $this->parent->name, + InstitutionResource::getUrl('user.view', [ + 'parent' => $this->parent, + 'record' => $this->getRecord(), + ]) => $this->getRecord()->full_name, + ]; + } + + public function getTitle(): string|Htmlable + { + return $this->getRecord()->full_name; + } + + public function form(Form $form): Form + { + return $form->schema([ + Section::make() + ->maxWidth('3xl') + ->columns() + ->schema(self::getSchema()), + ]); + } + + public static function getSchema(): array + { + return [ + TextInput::make('first_name') + ->label(__('institution.labels.first_name')), + + TextInput::make('last_name') + ->label(__('institution.labels.last_name')), + + TextInput::make('email') + ->label(__('institution.labels.email')), + + TextInput::make('phone') + ->label(__('institution.labels.phone')), + ]; + } +} diff --git a/app/Filament/Admin/Resources/UserInstitutionResource/Pages/ListUserInstitutions.php b/app/Filament/Admin/Resources/UserInstitutionResource/Pages/ListUserInstitutions.php new file mode 100644 index 00000000..67c4c425 --- /dev/null +++ b/app/Filament/Admin/Resources/UserInstitutionResource/Pages/ListUserInstitutions.php @@ -0,0 +1,21 @@ + $this->parent, + 'activeRelationManager' => 'admins', + ]); + } + + public function getBreadcrumbs(): array + { + return [ + InstitutionResource::getUrl() => __('institution.headings.list_title'), + InstitutionResource::getUrl('view', ['record' => $this->parent]) => $this->parent->name, + InstitutionResource::getUrl('user.view', [ + 'parent' => $this->parent, + 'record' => $this->getRecord(), + ]) => $this->getRecord()->full_name, + ]; + } + + public function getTitle(): string|Htmlable + { + return $this->getRecord()->full_name; + } + + protected function getHeaderActions(): array + { + return [ + ActivateUserAction::make(), + + DeactivateUserAction::make(), + + ResendInvitationAction::make(), + ]; + } + + public function infolist(Infolist $infolist): Infolist + { + return $infolist->schema([ + Section::make() + ->maxWidth('3xl') + ->columns() + ->schema([ + TextEntry::make('status') + ->formatStateUsing(fn ($state) => $state === '-' ? $state : $state->label()), + TextEntry::make('updated_at'), + ]), + Section::make() + ->maxWidth('3xl') + ->columns() + ->schema([ + SectionHeader::make('edit_user') + ->action( + Action::make('edit') + ->label(__('general.action.edit')) + ->link() + ->url(self::getParentResource()::getUrl('user.edit', [ + 'parent' => $this->parent, + 'record' => $this->getRecord(), + ])) + ), + + TextEntry::make('first_name') + ->label(__('user.labels.first_name')), + TextEntry::make('last_name') + ->label(__('user.labels.last_name')), + TextEntry::make('email') + ->label(__('user.labels.email')), + TextEntry::make('phone_number') + ->label(__('user.labels.phone_number')), + ]), + ]); + } +} diff --git a/app/Filament/Organizations/Pages/ViewOrganizationTenant.php b/app/Filament/Organizations/Pages/ViewOrganizationTenant.php new file mode 100644 index 00000000..76c02ee9 --- /dev/null +++ b/app/Filament/Organizations/Pages/ViewOrganizationTenant.php @@ -0,0 +1,67 @@ +schema([ + Section::make() + ->maxWidth('3xl') + ->schema([ + Notice::make('notice') + ->icon('heroicon-s-information-circle') + ->state(__('organization.helper_texts.view_tenant_info')) + ->color('primary'), + + Grid::make() + ->relationship('institution') + ->schema(ViewInstitution::getInfolistSchema()), + + Grid::make() + ->schema(OrganizationsRelationManager::getOrganizationInfolistSchema()), + ]), + + ]) + ->state(Filament::getTenant()->load('institution')->toArray()); + } +} diff --git a/app/Models/Institution.php b/app/Models/Institution.php new file mode 100644 index 00000000..ab508e41 --- /dev/null +++ b/app/Models/Institution.php @@ -0,0 +1,114 @@ + OrganizationType::class, + 'status' => InstitutionStatus::class, + ]; + + protected function getSlugSource(): string + { + return $this->name; + } + + public function organizations(): HasMany + { + return $this->hasMany(Organization::class); + } + + public function admins(): HasMany + { + return $this->hasMany(User::class); + } + + public function beneficiaries(): HasManyThrough + { + return $this->hasManyThrough(Beneficiary::class, Organization::class); + } + + public function users(): HasManyDeep + { + return $this->hasManyDeep( + User::class, + [Organization::class, 'model_has_organizations'], + ['institution_id', null, 'id'], + ['id', 'id', 'model_id'] + )->where('model_type', 'user'); + } + + public function county(): BelongsTo + { + return $this->belongsTo(County::class); + } + + public function city(): BelongsTo + { + return $this->belongsTo(City::class); + } + + public function getCountyAndCityAttribute(): string + { + return $this->city?->name . ' (' . $this->county?->name . ')'; + } + + public function inactivate(): void + { + $this->update(['status' => InstitutionStatus::INACTIVE->value]); + } + + public function isInactivated(): bool + { + return InstitutionStatus::isValue($this->status, InstitutionStatus::INACTIVE); + } + + public function activate(): void + { + $this->update(['status' => InstitutionStatus::ACTIVE->value]); + } + + public function isActivated(): bool + { + return InstitutionStatus::isValue($this->status, InstitutionStatus::ACTIVE); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 2298a50e..f77688c0 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -14,6 +14,7 @@ use Filament\Models\Contracts\HasName; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphToMany; @@ -34,20 +35,18 @@ class Organization extends Model implements HasAvatar, HasMedia, HasName, HasCur 'name', 'slug', 'short_name', - 'type', - 'cif', 'main_activity', - 'address', - 'reprezentative_name', - 'reprezentative_email', - 'phone', - 'website', ]; protected $casts = [ 'type' => OrganizationType::class, ]; + public function institution(): BelongsTo + { + return $this->belongsTo(Institution::class); + } + public function users(): MorphToMany { return $this->morphedByMany(User::class, 'model', 'model_has_organizations'); diff --git a/app/Models/User.php b/app/Models/User.php index 5fe4aa20..2641d62a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -63,6 +63,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasName, 'password_set_at', 'latest_organization_id', 'is_admin', + 'ngo_admin', ]; /** @@ -96,6 +97,17 @@ protected static function booted() static::creating(function (User $model) { $model->setPendingStatus(); }); + + static::created(function (User $model) { + if ($model->institution) { + $model->organizations() + ->attach( + $model->institution + ->organizations + ?->pluck('id') + ); + } + }); } public function organizations(): MorphToMany @@ -108,6 +120,11 @@ public function latestOrganization(): BelongsTo return $this->belongsTo(Organization::class, 'latest_organization_id'); } + public function institution(): BelongsTo + { + return $this->belongsTo(Institution::class); + } + public function roles(): BelongsToMany { return $this->belongsToMany(Role::class, 'user_roles') diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a493e33f..7c59eada 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use App\Models\Document; use App\Models\EvaluateDetails; use App\Models\FlowPresentation; +use App\Models\Institution; use App\Models\Intervention; use App\Models\Meeting; use App\Models\Monitoring; @@ -73,6 +74,7 @@ public function boot(): void protected function enforceMorphMap(): void { Relation::enforceMorphMap([ + 'institution' => Institution::class, 'beneficiary' => Beneficiary::class, 'city' => City::class, 'community_profile' => CommunityProfile::class, diff --git a/composer.json b/composer.json index aecd5a52..3dcc39c2 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "sentry/sentry-laravel": "^4.7", "spatie/laravel-activitylog": "^4.8", "staudenmeir/belongs-to-through": "^2.5", + "staudenmeir/eloquent-has-many-deep": "^1.7", "stevegrunwell/time-constants": "^1.2", "tpetry/laravel-query-expressions": "^0.9" }, diff --git a/composer.lock b/composer.lock index 15955301..0779bae3 100644 --- a/composer.lock +++ b/composer.lock @@ -7282,6 +7282,115 @@ ], "time": "2023-12-19T11:58:06+00:00" }, + { + "name": "staudenmeir/eloquent-has-many-deep", + "version": "v1.19.4", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-has-many-deep.git", + "reference": "d9651c2c64d34a8fd4c680090d3521ed136f2ead" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep/zipball/d9651c2c64d34a8fd4c680090d3521ed136f2ead", + "reference": "d9651c2c64d34a8fd4c680090d3521ed136f2ead", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0", + "php": "^8.1", + "staudenmeir/eloquent-has-many-deep-contracts": "^1.1" + }, + "require-dev": { + "awobaz/compoships": "^2.2", + "barryvdh/laravel-ide-helper": "^2.13", + "illuminate/pagination": "^10.0", + "korridor/laravel-has-many-merged": "^1.0", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.13", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1", + "staudenmeir/eloquent-eager-limit": "^1.8", + "staudenmeir/eloquent-json-relations": "^1.8.2", + "staudenmeir/laravel-adjacency-list": "^1.13.7" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Staudenmeir\\EloquentHasManyDeep\\IdeHelperServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentHasManyDeep\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Laravel Eloquent HasManyThrough relationships with unlimited levels", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-has-many-deep/issues", + "source": "https://github.com/staudenmeir/eloquent-has-many-deep/tree/v1.19.4" + }, + "funding": [ + { + "url": "https://paypal.me/JonasStaudenmeir", + "type": "custom" + } + ], + "time": "2024-05-25T09:46:08+00:00" + }, + { + "name": "staudenmeir/eloquent-has-many-deep-contracts", + "version": "v1.1", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git", + "reference": "c39317b839d6123be126b9980e4a3d38310f5939" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/c39317b839d6123be126b9980e4a3d38310f5939", + "reference": "c39317b839d6123be126b9980e4a3d38310f5939", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentHasManyDeepContracts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Contracts for staudenmeir/eloquent-has-many-deep", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues", + "source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.1" + }, + "time": "2023-01-18T12:43:26+00:00" + }, { "name": "stevegrunwell/time-constants", "version": "v1.2.0", @@ -14914,5 +15023,5 @@ "php": "^8.2" }, "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.2.0" } diff --git a/database/factories/InstitutionFactory.php b/database/factories/InstitutionFactory.php new file mode 100644 index 00000000..290ff945 --- /dev/null +++ b/database/factories/InstitutionFactory.php @@ -0,0 +1,60 @@ + + */ +class InstitutionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = fake()->company(); + $city = City::query()->inRandomOrder()->first(); + + return [ + 'name' => $name, + 'short_name' => preg_replace('/\b(\w)|./u', '$1', $name), + 'type' => fake()->randomElement(OrganizationType::values()), + 'phone' => fake()->phoneNumber(), + 'website' => fake()->url(), + + 'city_id' => $city->id, + 'county_id' => $city->county_id, + 'address' => fake()->streetAddress(), + + 'reprezentative_name' => fake()->name(), + 'reprezentative_email' => fake()->safeEmail(), + + 'status' => fake()->randomElement(InstitutionStatus::values()), + ]; + } + + public function withOrganization() + { + return $this->afterCreating(function (Institution $institution) { + Organization::factory() + ->for($institution) + ->count(2) + ->withUsers() + ->withBeneficiaries() + ->withCommunityProfile() + ->withInterventions() + ->create(); + }); + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index acecb535..c35b36be 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -4,9 +4,7 @@ namespace Database\Factories; -use App\Enums\OrganizationType; use App\Models\Beneficiary; -use App\Models\City; use App\Models\CommunityProfile; use App\Models\Intervention; use App\Models\Organization; @@ -28,21 +26,10 @@ class OrganizationFactory extends Factory public function definition(): array { $name = fake()->company(); - $city = City::query()->inRandomOrder()->first(); return [ 'name' => $name, 'short_name' => preg_replace('/\b(\w)|./u', '$1', $name), - 'type' => fake()->randomElement(OrganizationType::values()), - 'phone' => fake()->phoneNumber(), - 'website' => fake()->url(), - - 'city_id' => $city->id, - 'county_id' => $city->county_id, - 'address' => fake()->streetAddress(), - - 'reprezentative_name' => fake()->name(), - 'reprezentative_email' => fake()->safeEmail(), ]; } @@ -54,6 +41,8 @@ public function withUsers(int $count = 5): static ->count($count) ->sequence(fn (Sequence $sequence) => [ 'email' => \sprintf('user-%d-%d@example.com', $organization->id, $sequence->index + 1), + 'institution_id' => $sequence->index === 0 ? $organization->institution_id : null, + 'ngo_admin' => $sequence->index === 0, ]) ->withRolesAndPermissions($organization->id) ->create() @@ -72,7 +61,7 @@ public function withCommunityProfile(): static }); } - public function withBeneficiaries(int $count = 50): static + public function withBeneficiaries(int $count = 30): static { return $this->afterCreating(function (Organization $organization) use ($count) { Beneficiary::factory() diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 2b9c8e82..c120f186 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -5,6 +5,7 @@ namespace Database\Factories; use App\Enums\UserStatus; +use App\Models\Institution; use App\Models\Organization; use App\Models\OrganizationUserPermissions; use App\Models\Role; @@ -60,8 +61,11 @@ public function admin(): static public function withOrganization(): static { return $this->afterCreating(function (User $user) { + $institution = Institution::factory() + ->create(); $user->organizations()->attach( Organization::factory() + ->for($institution) ->create() ); }); diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 02d29d84..1f66534f 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -22,6 +22,7 @@ public function up(): void $table->string('status')->default(UserStatus::PENDING); $table->boolean('has_access_to_all_cases')->nullable(); $table->boolean('is_admin')->default(false); + $table->boolean('ngo_admin')->default(false); $table->timestamp('password_set_at')->nullable(); $table->string('password'); $table->rememberToken(); diff --git a/database/migrations/2023_10_28_192350_create_institutions_table.php b/database/migrations/2023_10_28_192350_create_institutions_table.php new file mode 100644 index 00000000..06e66d46 --- /dev/null +++ b/database/migrations/2023_10_28_192350_create_institutions_table.php @@ -0,0 +1,52 @@ +id(); + $table->ulid()->unique(); + + $table->string('name'); + $table->string('slug')->unique()->nullable(); + $table->string('short_name')->nullable(); + $table->string('type')->nullable(); + $table->string('cif')->nullable(); + $table->string('main_activity')->nullable(); + + $table->foreignIdFor(County::class)->nullable()->constrained()->cascadeOnDelete(); + $table->foreignIdFor(City::class)->nullable()->constrained()->cascadeOnDelete(); + $table->string('address')->nullable(); + + $table->string('reprezentative_name')->nullable(); + $table->string('reprezentative_email')->nullable(); + $table->string('phone')->nullable(); + $table->string('website')->nullable(); + + $table->string('status')->default(InstitutionStatus::PENDING->value); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('institutions'); + } +}; diff --git a/database/migrations/2023_11_20_124722_create_organizations_table.php b/database/migrations/2023_11_20_124722_create_organizations_table.php index c3b2ee68..3c7e4994 100644 --- a/database/migrations/2023_11_20_124722_create_organizations_table.php +++ b/database/migrations/2023_11_20_124722_create_organizations_table.php @@ -2,8 +2,7 @@ declare(strict_types=1); -use App\Models\City; -use App\Models\County; +use App\Models\Institution; use App\Models\Organization; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; @@ -16,22 +15,13 @@ public function up(): void Schema::create('organizations', function (Blueprint $table) { $table->id(); $table->ulid()->unique(); - $table->timestamps(); + $table->foreignIdFor(Institution::class)->constrained()->cascadeOnDelete(); $table->string('name'); $table->string('slug')->unique()->nullable(); $table->string('short_name')->nullable(); - $table->string('type')->nullable(); - $table->string('cif')->nullable(); $table->string('main_activity')->nullable(); - $table->foreignIdFor(County::class)->nullable()->constrained()->cascadeOnDelete(); - $table->foreignIdFor(City::class)->nullable()->constrained()->cascadeOnDelete(); - $table->string('address')->nullable(); - - $table->string('reprezentative_name')->nullable(); - $table->string('reprezentative_email')->nullable(); - $table->string('phone')->nullable(); - $table->string('website')->nullable(); + $table->timestamps(); }); Schema::create('model_has_organizations', function (Blueprint $table) { diff --git a/database/migrations/2024_10_29_084836_add_institution_in_users_table.php b/database/migrations/2024_10_29_084836_add_institution_in_users_table.php new file mode 100644 index 00000000..bcd0379c --- /dev/null +++ b/database/migrations/2024_10_29_084836_add_institution_in_users_table.php @@ -0,0 +1,31 @@ +foreignIdFor(Institution::class)->nullable()->constrained()->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a194d838..c1879299 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -6,7 +6,7 @@ use App\Models\Benefit; use App\Models\Country; -use App\Models\Organization; +use App\Models\Institution; use App\Models\Role; use App\Models\Service; use App\Models\User; @@ -46,12 +46,9 @@ public function run(): void ->count(20) ->create(); - Organization::factory() + Institution::factory() ->count(2) - ->withUsers() - ->withBeneficiaries() - ->withCommunityProfile() - ->withInterventions() + ->withOrganization() ->create(); } } diff --git a/lang/ro/enum.php b/lang/ro/enum.php index 746080fc..a4f7c46c 100644 --- a/lang/ro/enum.php +++ b/lang/ro/enum.php @@ -370,4 +370,10 @@ 'no' => 'Nu', 'unknown' => 'Nu știe/ Nu răspunde', ], + + 'institution_status' => [ + 'active' => 'Activ', + 'inactive' => 'Suspendat', + 'pending' => 'În așteptare', + ], ]; diff --git a/lang/ro/institution.php b/lang/ro/institution.php new file mode 100644 index 00000000..013c277b --- /dev/null +++ b/lang/ro/institution.php @@ -0,0 +1,60 @@ + [ + 'institution_name' => 'Nume instituție', + 'registered_office' => 'Sediu social', + 'centers' => 'Centre', + 'cases' => 'Cazuri', + 'specialists' => 'Specialiști', + 'status' => 'Status', + 'list_title' => 'Utilizatori instituționali', + 'empty_state' => 'Niciun utilizator instituțional identificat.', + 'institution_details' => 'Detalii organizație', + 'center_details' => 'Centre', + 'ngo_admin' => 'Administrator', + 'inactivate' => 'Dezactivează organizație', + 'admin_users' => 'Utilizatori de tip administrator', + ], + + 'labels' => [ + 'organization_status' => 'Statut organizație sau hotărâre de înființare', + 'social_service_provider_certificate' => 'Certificat furnizor de servicii sociale', + 'center_name' => 'Nume centru', + 'social_service_licensing_certificate' => 'Certificat de licențiere serviciu social', + 'logo_center' => 'Logo centru', + 'organization_header' => 'Antet centru', + 'first_name' => 'Nume', + 'last_name' => 'Prenume', + 'email' => 'Email', + 'phone' => 'Telefon', + 'inactivate' => 'Odată dezactivată o organizație, utilizatorii acesteia nu vor mai ave acces în platformă. Toate datele asociate organizației vor rămâne în baza de date. Pentru a oferi din nou acces utilizatorilor, organizația va trebui Reactivată din profilul acesteia.', + 'roles' => 'Roluri', + 'account_status' => 'Cont', + 'last_login_at' => 'Ultima accesare', + ], + + 'actions' => [ + 'create' => 'Adaugă o instituție', + 'add_organization' => 'Adaugă încă un centru', + 'add_admin' => 'Adaugă încă un administrator', + 'activate' => 'Reactivează organizație', + 'inactivate' => 'Deactivează organizație', + 'add_ngo_admin' => 'Adaugă administrator', + ], + + 'placeholders' => [ + 'center_details' => 'Dacă instituția are multiple centre acreditate pentru servicii diferite și necesită menținearea unor baze de date diferite de beneficiari, se pot crea tenants (profile) diferite pentru fiecare dintre acestea.', + 'ngo_admins' => 'Adăugați cel puțin un rol de administrator în sistem. Această persoană are drepturi depline asupra întregii aplicații Sunrise pentru toate centrele instituției (ale organizației). Un email de invitație va fi transmis administratorului odată cu finalizarea adăugării instituției.', + ], + + 'helper_texts' => [ + 'organization_status' => 'Încarcă statutul în format .pdf, .jpg sau .png', + 'social_service_provider_certificate' => 'Încarcă certificatul de furnizor de servicii sociale în format .pdf, .jpg sau .png', + 'social_service_licensing_certificate' => 'Încarcă certificatul de licențiere pentru serviciul social în format .pdf, .jpg sau .png', + 'logo' => 'Încarcă un logo pentru centru, care să fie folosit în interfață', + 'organization_header' => 'Încarcă un antet pentru centru, care să fie folosit pentru fișiele exportate', + ], +]; diff --git a/lang/ro/organization.php b/lang/ro/organization.php index c6c94fd5..2a361b86 100644 --- a/lang/ro/organization.php +++ b/lang/ro/organization.php @@ -37,4 +37,8 @@ 'placeholder' => 'Național', ], ], + + 'helper_texts' => [ + 'view_tenant_info' => 'Pentru a modifica/ actualiza informațiile din această secțiune, vă rugăm contactați echipa de administrare Sunrise via email la admin@stopviolențeidomestice.ro ', + ], ]; diff --git a/lang/ro/user.php b/lang/ro/user.php index ab82ba4d..3be0f9c7 100644 --- a/lang/ro/user.php +++ b/lang/ro/user.php @@ -52,6 +52,7 @@ 'deactivate' => 'Deactivează cont', 'reset_password' => 'Resetează parola', 'resend_invitation' => 'Retrimite invitația', + 'activate' => 'Reactivează cont', ], 'action_resend_invitation_confirm' => [ diff --git a/resources/views/filament/organizations/pages/view-organization-tenant.blade.php b/resources/views/filament/organizations/pages/view-organization-tenant.blade.php new file mode 100644 index 00000000..840d8a6f --- /dev/null +++ b/resources/views/filament/organizations/pages/view-organization-tenant.blade.php @@ -0,0 +1,7 @@ + +
+ + {{ $this->infolist() }} + +
+
diff --git a/resources/views/infolists/infolist-relation-manager.blade.php b/resources/views/infolists/infolist-relation-manager.blade.php new file mode 100644 index 00000000..abc22a14 --- /dev/null +++ b/resources/views/infolists/infolist-relation-manager.blade.php @@ -0,0 +1,11 @@ +
+ + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_RELATION_MANAGER_BEFORE, scopes: $this->getRenderHookScopes()) }} + + {{ $this->infolist }} + + {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_RELATION_MANAGER_AFTER, scopes: $this->getRenderHookScopes()) }} + + +