Compare commits
10 Commits
eee1bdb509
...
72721adaff
| Author | SHA1 | Date | |
|---|---|---|---|
| 72721adaff | |||
| ab3a27d462 | |||
| 1790214fff | |||
| c3b64e4bb9 | |||
| 74c030de65 | |||
| 6e73c82787 | |||
| 45920c083e | |||
| d8500a251d | |||
| e88d12e99c | |||
| 3389316aef |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -23,3 +23,12 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
gitlab-ci.yml
|
||||
|
||||
# Agents
|
||||
/.claude
|
||||
/.junie
|
||||
.claude
|
||||
.junie
|
||||
.ai-rules.md
|
||||
CLAUDE.md
|
||||
database-schema.md
|
||||
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
193
PROJECT_STRUCTURE.md
Normal file
193
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Roxane - Structure du projet
|
||||
|
||||
Roxane est une application de gestion d'adhésions pour **Le Retzien Libre**, une association libre/open source.
|
||||
Elle gère les membres, les cotisations, et s'intègre avec des services tiers (Dolibarr, ISPConfig, Nextcloud).
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologies |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| Backend | Laravel 12, PHP 8.4, MySQL |
|
||||
| Admin | Filament v4, Livewire 3 |
|
||||
| Frontend | React 19, Inertia v2, Tailwind CSS v4 |
|
||||
| Auth | Laravel Fortify, 2FA, Spatie Permissions + Shield |
|
||||
| Tests | PHPUnit 11 |
|
||||
| DevTools | Pint, ESLint, Prettier, Sail, Wayfinder |
|
||||
|
||||
---
|
||||
|
||||
## Modeles Eloquent
|
||||
|
||||
### User
|
||||
- **Traits** : HasRoles, TwoFactorAuthenticatable
|
||||
- **Relations** : `members()` hasMany(Member)
|
||||
- **Role** : Implemente `FilamentUser` pour l'acces admin.
|
||||
|
||||
### Member
|
||||
- **Table** : `members` (soft deletes)
|
||||
- **Statuts** : draft, valid, pending, cancelled, excluded
|
||||
- **Natures** : physical, legal
|
||||
- **Relations** :
|
||||
- `user()` belongsTo(User)
|
||||
- `group()` belongsTo(MemberGroup)
|
||||
- `memberships()` hasMany(Membership)
|
||||
- `ispconfigs()` hasMany(IspconfigMember)
|
||||
- `nextcloudAccounts()` hasMany(NextCloudMember)
|
||||
- **Accessors** : `full_name`, `retzien_email`
|
||||
- **Methodes** : `lastMembership()`, `hasService()`, `isExpired()`
|
||||
|
||||
### Membership
|
||||
- **Table** : `memberships` (soft deletes)
|
||||
- **Statuts** : active, expired, pending
|
||||
- **Statuts paiement** : paid, unpaid, partial
|
||||
- **Relations** :
|
||||
- `member()` belongsTo(Member)
|
||||
- `author()` belongsTo(User, 'admin_id')
|
||||
- `package()` belongsTo(Package)
|
||||
- `services()` belongsToMany(Service) via `services_memberships`
|
||||
|
||||
### Package
|
||||
- **Table** : `packages`
|
||||
- **Donnees** : custom (1EUR), one-year (12EUR), two-years (24EUR)
|
||||
|
||||
### Service
|
||||
- **Table** : `services`
|
||||
- **Relations** : `memberships()` belongsToMany(Membership)
|
||||
- **Donnees** : mail (RoundCube), file2link, nextcloud, sympa, webhosting
|
||||
|
||||
### MemberGroup
|
||||
- **Table** : `member_groups`
|
||||
- **Relations** : `members()` hasMany(Member)
|
||||
- **Donnees** : admin-interface, website
|
||||
|
||||
### Contact
|
||||
- **Table** : `contacts`
|
||||
|
||||
### IspconfigMember
|
||||
- **Table** : `ispconfigs_members`
|
||||
- **Cast** : `type` vers IspconfigType enum, `data` vers array
|
||||
|
||||
### NextCloudMember
|
||||
- **Table** : `nextclouds_members`
|
||||
|
||||
---
|
||||
|
||||
## Enums
|
||||
|
||||
| Enum | Valeurs |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| IspconfigType | MAIL ("Email"), WEB ("Hebergement"), OTHER ("Autre") |
|
||||
|
||||
---
|
||||
|
||||
## Services (couche metier)
|
||||
|
||||
| Service | Responsabilite |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| ContactService | Creation de demandes de contact |
|
||||
| MemberService | Inscription et desactivation de membres |
|
||||
| DolibarrService | Integration ERP via API REST |
|
||||
| ISPConfigMailService | Gestion comptes mail via SOAP |
|
||||
| ISPConfigWebService | Gestion hebergement web via SOAP (avec cache) |
|
||||
| NextcloudService | Gestion comptes Nextcloud via OCS (avec cache 7 jours) |
|
||||
|
||||
---
|
||||
|
||||
## Commandes Artisan (synchronisation)
|
||||
|
||||
| Commande | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `sync:dolibarr-members` | Importe les membres et cotisations depuis Dolibarr |
|
||||
| `members:cleanup-expired` | Desactive les membres expires (Dolibarr + ISPConfig + Nextcloud). `--dry-run` |
|
||||
| `sync:ispconfig-mail-members` | Lie les membres a leurs comptes mail ISPConfig (@retzien.fr) |
|
||||
| `sync:ispconfig-web-members` | Lie les membres a leurs comptes d'hebergement web |
|
||||
| `sync:nextcloud-members` | Lie les membres a leurs comptes Nextcloud |
|
||||
| `sync:services-members` | Synchronise les services associes aux membres |
|
||||
|
||||
---
|
||||
|
||||
## Panel Admin (Filament v4)
|
||||
|
||||
### Structure des ressources
|
||||
|
||||
```
|
||||
app/Filament/Resources/<Nom>/
|
||||
├── <Nom>Resource.php
|
||||
├── Schemas/<Nom>Form.php
|
||||
├── Tables/<Nom>sTable.php
|
||||
├── Pages/ (List, Create, Edit)
|
||||
├── Widgets/ (optionnel)
|
||||
└── RelationManagers/ (optionnel)
|
||||
```
|
||||
|
||||
**Ressources** : MemberResource, MembershipResource, PackageResource, ServiceResource, MemberGroupResource, UserResource
|
||||
|
||||
**Pages custom** : Synchronisations (groupe "Paramètres" — lancement manuel des commandes Artisan)
|
||||
|
||||
**Widgets** : MemberCount, MembershipsChart
|
||||
|
||||
**Actions custom** : ServiceToggleAction
|
||||
|
||||
### Permissions (Spatie + Shield)
|
||||
|
||||
Format : `Action:Modele` en PascalCase (ex: `ViewAny:Member`, `Create:Membership`)
|
||||
|
||||
Roles :
|
||||
- `super_admin` : acces complet
|
||||
- `panel_user` : acces au panel avec permissions specifiques
|
||||
|
||||
---
|
||||
|
||||
## Frontend (React + Inertia v2)
|
||||
|
||||
### Pages
|
||||
|
||||
```
|
||||
resources/js/pages/
|
||||
├── welcome.tsx / maintenance.tsx / dashboard.tsx
|
||||
├── auth/ (login, register, forgot-password, reset-password, verify-email, two-factor-challenge)
|
||||
├── forms/ (contact.tsx, membership.tsx)
|
||||
└── settings/ (profile, password, two-factor, appearance)
|
||||
```
|
||||
|
||||
### Composants principaux
|
||||
|
||||
```
|
||||
resources/js/components/
|
||||
├── app-shell.tsx / app-header.tsx / app-sidebar.tsx
|
||||
├── nav-main.tsx / nav-user.tsx / nav-footer.tsx
|
||||
├── breadcrumbs.tsx / flash-message.tsx
|
||||
├── two-factor-setup-modal.tsx / appearance-tabs.tsx
|
||||
└── ui/ (Shadcn/ui)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
| Classe | Canal | Description |
|
||||
| ------------------------- | ----- | --------------------------------------------- |
|
||||
| SubscriptionExpiredPhase1 | Email | Notification d'expiration d'adhesion (queued) |
|
||||
|
||||
---
|
||||
|
||||
## Localisation
|
||||
|
||||
Langues : **fr**, **en** — fichiers dans `lang/{locale}/` : contacts, members, memberships, packages, services, users, member_groups.
|
||||
|
||||
---
|
||||
|
||||
## TODOs identifies dans le code
|
||||
|
||||
| Fichier | TODO |
|
||||
|---------------------------|----------------------------------------------------------------------------|
|
||||
| ContactService | Envoyer un email a l'administrateur |
|
||||
| MemberService | Envoyer des emails au membre + admin a la desactivation |
|
||||
| SubscriptionExpiredPhase1 | Creer un template generique + UI backend pour le contenu |
|
||||
| User.php | Restreindre l'acces admin en prod aux emails @retzien.fr |
|
||||
| SyncDolibarrMembers | Exporter la methode toDate() dans un service/helper |
|
||||
| SyncISPConfigMailMembers | Gerer plusieurs emails par membre |
|
||||
| SyncISPConfigMailMembers | Ajouter le suivi ispconfig_client_id |
|
||||
| Global | Refactoriser pour rendre générique le projet Roxane (ERP pour association) |
|
||||
| Traduction | Crawler le prrojet pour retrouver toutes les clés manquantes |
|
||||
| Global | PHPstan niveau 8 |
|
||||
@@ -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
|
||||
|
||||
@@ -2,35 +2,13 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MemberRegistered
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('channel-name'),
|
||||
];
|
||||
}
|
||||
public function __construct(public readonly Member $member) {}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,13 @@
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class MemberValidated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [
|
||||
new PrivateChannel('channel-name'),
|
||||
];
|
||||
}
|
||||
public function __construct(public readonly Member $member) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Filament/Pages/Synchronisations.php
Normal file
171
app/Filament/Pages/Synchronisations.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Jobs\RunSyncCommand;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use UnitEnum;
|
||||
|
||||
class Synchronisations extends Page
|
||||
{
|
||||
protected string $view = 'filament.pages.synchronisations';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Paramètres';
|
||||
|
||||
protected static ?string $navigationLabel = 'Synchronisations';
|
||||
|
||||
protected static ?string $title = 'Synchronisations';
|
||||
|
||||
protected static ?string $slug = 'synchronisations';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
private const array CACHE_KEYS = [
|
||||
'dolibarr',
|
||||
'cleanup_expired',
|
||||
'ispconfig_mail',
|
||||
'ispconfig_web',
|
||||
'nextcloud',
|
||||
'services',
|
||||
];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth()->user()?->hasRole('super_admin') ?? false;
|
||||
}
|
||||
|
||||
public function getCommandStatus(string $key): array
|
||||
{
|
||||
return Cache::get("sync_run.{$key}", [
|
||||
'status' => 'idle',
|
||||
'output' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function hasRunningCommands(): bool
|
||||
{
|
||||
foreach (self::CACHE_KEYS as $key) {
|
||||
if (in_array($this->getCommandStatus($key)['status'], ['pending', 'running'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function enqueueCommand(string $key, string $command, array $parameters = []): void
|
||||
{
|
||||
Cache::put("sync_run.{$key}", [
|
||||
'status' => 'pending',
|
||||
'output' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
], now()->addHour());
|
||||
|
||||
RunSyncCommand::dispatch($command, $parameters, $key);
|
||||
}
|
||||
|
||||
public function syncDolibarrAction(): Action
|
||||
{
|
||||
return Action::make('syncDolibarr')
|
||||
->label('Lancer')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Synchronisation Dolibarr')
|
||||
->modalDescription('Importer les membres et cotisations depuis Dolibarr.')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->disabled(fn () => in_array($this->getCommandStatus('dolibarr')['status'], ['pending', 'running']))
|
||||
->action(fn () => $this->enqueueCommand('dolibarr', 'sync:dolibarr-members'));
|
||||
}
|
||||
|
||||
public function cleanupExpiredAction(): Action
|
||||
{
|
||||
return Action::make('cleanupExpired')
|
||||
->label('Lancer')
|
||||
->modalHeading('Désactiver les membres expirés')
|
||||
->modalDescription('Désactive les membres expirés dans Dolibarr, ISPConfig et Nextcloud.')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->schema([
|
||||
Toggle::make('dry_run')
|
||||
->label('Mode simulation (dry-run)')
|
||||
->helperText('Simule l\'opération sans effectuer de modifications.')
|
||||
->default(true),
|
||||
])
|
||||
->disabled(fn () => in_array($this->getCommandStatus('cleanup_expired')['status'], ['pending', 'running']))
|
||||
->action(function (array $data) {
|
||||
$parameters = $data['dry_run'] ? ['--dry-run' => true] : [];
|
||||
$this->enqueueCommand('cleanup_expired', 'members:cleanup-expired', $parameters);
|
||||
});
|
||||
}
|
||||
|
||||
public function syncISPConfigMailAction(): Action
|
||||
{
|
||||
return Action::make('syncISPConfigMail')
|
||||
->label('Lancer')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Synchronisation ISPConfig Mail')
|
||||
->modalDescription('Lie les membres à leurs comptes mail ISPConfig (@retzien.fr).')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->disabled(fn () => in_array($this->getCommandStatus('ispconfig_mail')['status'], ['pending', 'running']))
|
||||
->action(fn () => $this->enqueueCommand('ispconfig_mail', 'sync:ispconfig-mail-members'));
|
||||
}
|
||||
|
||||
public function syncISPConfigWebAction(): Action
|
||||
{
|
||||
return Action::make('syncISPConfigWeb')
|
||||
->label('Lancer')
|
||||
->modalHeading('Synchronisation ISPConfig Web')
|
||||
->modalDescription('Lie les membres à leurs comptes d\'hébergement web.')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->schema([
|
||||
Toggle::make('refresh_cache')
|
||||
->label('Vider le cache ISPConfig')
|
||||
->helperText('Vide le cache avant la synchronisation.')
|
||||
->default(false),
|
||||
])
|
||||
->disabled(fn () => in_array($this->getCommandStatus('ispconfig_web')['status'], ['pending', 'running']))
|
||||
->action(function (array $data) {
|
||||
$parameters = $data['refresh_cache'] ? ['--refresh-cache' => true] : [];
|
||||
$this->enqueueCommand('ispconfig_web', 'sync:ispconfig-web-members', $parameters);
|
||||
});
|
||||
}
|
||||
|
||||
public function syncNextcloudAction(): Action
|
||||
{
|
||||
return Action::make('syncNextcloud')
|
||||
->label('Lancer')
|
||||
->modalHeading('Synchronisation Nextcloud')
|
||||
->modalDescription('Lie les membres à leurs comptes Nextcloud.')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->schema([
|
||||
Toggle::make('dry_run')
|
||||
->label('Mode simulation (dry-run)')
|
||||
->helperText('Simule l\'opération sans effectuer de modifications.')
|
||||
->default(false),
|
||||
])
|
||||
->disabled(fn () => in_array($this->getCommandStatus('nextcloud')['status'], ['pending', 'running']))
|
||||
->action(function (array $data) {
|
||||
$parameters = $data['dry_run'] ? ['--dry-run' => true] : [];
|
||||
$this->enqueueCommand('nextcloud', 'nextcloud:sync-members', $parameters);
|
||||
});
|
||||
}
|
||||
|
||||
public function syncServicesAction(): Action
|
||||
{
|
||||
return Action::make('syncServices')
|
||||
->label('Lancer')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Synchronisation des services')
|
||||
->modalDescription('Synchronise les services associés aux membres actifs.')
|
||||
->modalSubmitActionLabel('Lancer')
|
||||
->disabled(fn () => in_array($this->getCommandStatus('services')['status'], ['pending', 'running']))
|
||||
->action(fn () => $this->enqueueCommand('services', 'memberships:sync-services'));
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class MemberResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
RelationManagers\MembershipsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -65,5 +65,4 @@ class MemberResource extends Resource
|
||||
MemberCount::class,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Members\RelationManagers;
|
||||
|
||||
use App\Models\Membership;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
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
|
||||
{
|
||||
protected static string $relationship = 'memberships';
|
||||
|
||||
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
|
||||
->recordTitleAttribute('start_date')
|
||||
->columns([
|
||||
TextColumn::make('start_date')
|
||||
->label(Membership::getAttributeLabel('start_date'))
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('end_date')
|
||||
->label(Membership::getAttributeLabel('end_date'))
|
||||
->date()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->label(Membership::getAttributeLabel('status'))
|
||||
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'active' => 'success',
|
||||
'expired' => 'danger',
|
||||
'pending' => 'warning',
|
||||
}),
|
||||
TextColumn::make('package.name')
|
||||
->label(Membership::getAttributeLabel('package_id')),
|
||||
TextColumn::make('amount')
|
||||
->label(Membership::getAttributeLabel('amount'))
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
TextColumn::make('payment_status')
|
||||
->label(Membership::getAttributeLabel('payment_status'))
|
||||
->formatStateUsing(fn (string $state) => Membership::getAttributeLabel($state))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'paid' => 'success',
|
||||
'unpaid' => 'danger',
|
||||
'partial' => 'warning',
|
||||
}),
|
||||
TextColumn::make('created_at')
|
||||
->label(Membership::getAttributeLabel('created_at'))
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('start_date', 'desc')
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates;
|
||||
|
||||
use App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate;
|
||||
use App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate;
|
||||
use App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates;
|
||||
use App\Filament\Resources\NotificationTemplates\Schemas\NotificationTemplateForm;
|
||||
use App\Filament\Resources\NotificationTemplates\Tables\NotificationTemplatesTable;
|
||||
use App\Models\NotificationTemplate;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class NotificationTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = NotificationTemplate::class;
|
||||
|
||||
protected static string|null|\UnitEnum $navigationGroup = 'Administration';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedEnvelope;
|
||||
|
||||
public static function getModelLabel(): string
|
||||
{
|
||||
return NotificationTemplate::getAttributeLabel('singular_name');
|
||||
}
|
||||
|
||||
public static function getPluralModelLabel(): string
|
||||
{
|
||||
return NotificationTemplate::getAttributeLabel('plural_name');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return NotificationTemplateForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return NotificationTemplatesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListNotificationTemplates::route('/'),
|
||||
'create' => CreateNotificationTemplate::route('/create'),
|
||||
'edit' => EditNotificationTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateNotificationTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditNotificationTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates\Pages;
|
||||
|
||||
use App\Filament\Resources\NotificationTemplates\NotificationTemplateResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListNotificationTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = NotificationTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates\Schemas;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class NotificationTemplateForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make(fn (?NotificationTemplate $record) => $record?->name ?? NotificationTemplate::getAttributeLabel('name'))
|
||||
->afterHeader([
|
||||
Toggle::make('is_active')
|
||||
->label(NotificationTemplate::getAttributeLabel('is_active'))
|
||||
->default(true),
|
||||
])
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(NotificationTemplate::getAttributeLabel('name'))
|
||||
->required(),
|
||||
TextInput::make('identifier')
|
||||
->label(NotificationTemplate::getAttributeLabel('identifier'))
|
||||
->required()
|
||||
->disabledOn('edit'),
|
||||
TextInput::make('subject')
|
||||
->label(NotificationTemplate::getAttributeLabel('subject'))
|
||||
->required()
|
||||
->helperText('Variables : {member_name}, {expiry_date}'),
|
||||
RichEditor::make('body')
|
||||
->label(NotificationTemplate::getAttributeLabel('body'))
|
||||
->required()
|
||||
->helperText('Variables : {member_name}, {expiry_date}')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\NotificationTemplates\Tables;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class NotificationTemplatesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(NotificationTemplate::getAttributeLabel('name'))
|
||||
->searchable(),
|
||||
TextColumn::make('identifier')
|
||||
->label(NotificationTemplate::getAttributeLabel('identifier'))
|
||||
->searchable(),
|
||||
TextColumn::make('subject')
|
||||
->label(NotificationTemplate::getAttributeLabel('subject'))
|
||||
->searchable(),
|
||||
IconColumn::make('is_active')
|
||||
->label(NotificationTemplate::getAttributeLabel('is_active'))
|
||||
->boolean(),
|
||||
TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
ForceDeleteBulkAction::make(),
|
||||
RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
app/Jobs/RunSyncCommand.php
Normal file
64
app/Jobs/RunSyncCommand.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
|
||||
class RunSyncCommand implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $command,
|
||||
public readonly array $parameters,
|
||||
public readonly string $cacheKey,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$startedAt = now()->toDateTimeString();
|
||||
|
||||
Cache::put("sync_run.{$this->cacheKey}", [
|
||||
'status' => 'running',
|
||||
'output' => null,
|
||||
'started_at' => $startedAt,
|
||||
'finished_at' => null,
|
||||
], now()->addHour());
|
||||
|
||||
try {
|
||||
$buffer = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, false);
|
||||
Artisan::call($this->command, $this->parameters, $buffer);
|
||||
$output = trim(preg_replace('/\x1b\[[0-9;]*m/', '', $buffer->fetch()));
|
||||
|
||||
Cache::put("sync_run.{$this->cacheKey}", [
|
||||
'status' => 'completed',
|
||||
'output' => $output,
|
||||
'started_at' => $startedAt,
|
||||
'finished_at' => now()->toDateTimeString(),
|
||||
], now()->addDay());
|
||||
} catch (\Throwable $e) {
|
||||
Cache::put("sync_run.{$this->cacheKey}", [
|
||||
'status' => 'failed',
|
||||
'output' => $e->getMessage(),
|
||||
'started_at' => $startedAt,
|
||||
'finished_at' => now()->toDateTimeString(),
|
||||
], now()->addDay());
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Cache::put("sync_run.{$this->cacheKey}", [
|
||||
'status' => 'failed',
|
||||
'output' => $exception->getMessage(),
|
||||
'started_at' => null,
|
||||
'finished_at' => now()->toDateTimeString(),
|
||||
], now()->addDay());
|
||||
}
|
||||
}
|
||||
33
app/Jobs/SendSubscriptionExpiredPhase1Notifications.php
Normal file
33
app/Jobs/SendSubscriptionExpiredPhase1Notifications.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Notifications\SubscriptionExpiredPhase1;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class SendSubscriptionExpiredPhase1Notifications implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$template = NotificationTemplate::findByIdentifier('subscription_expired_phase1');
|
||||
|
||||
if (! $template) {
|
||||
return;
|
||||
}
|
||||
|
||||
Member::query()
|
||||
->whereHas('memberships', fn ($query) => $query->where('status', 'expired'))
|
||||
->chunk(100, function ($members) use ($template) {
|
||||
foreach ($members as $member) {
|
||||
$member->notify(new SubscriptionExpiredPhase1($template));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\MemberValidated;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class NotifiyMemberOfValidation
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(MemberValidated $event): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\MemberRegistered;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class NotifyAdminForMembershipRequest
|
||||
{
|
||||
/**
|
||||
* Create the event listener.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*/
|
||||
public function handle(MemberRegistered $event): void
|
||||
{
|
||||
$admin = User::where('name', 'SuperAdmin')->first();
|
||||
|
||||
$admin->notify(new AdminNewUserPending($event->user));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IspconfigType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -46,6 +45,7 @@ use Illuminate\Notifications\Notifiable;
|
||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
||||
* @property-read int|null $notifications_count
|
||||
* @property-read \App\Models\User|null $user
|
||||
*
|
||||
* @method static \Database\Factories\MemberFactory factory($count = null, $state = [])
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newModelQuery()
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member newQuery()
|
||||
@@ -75,11 +75,13 @@ use Illuminate\Notifications\Notifiable;
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereUserId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereWebsiteUrl($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Member whereZipcode($value)
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class Member extends Model
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'dolibarr_id',
|
||||
@@ -100,7 +102,10 @@ class Member extends Model
|
||||
'phone1',
|
||||
'phone2',
|
||||
'public_membership',
|
||||
'website_url'
|
||||
'website_url',
|
||||
'member_type',
|
||||
'retzien_email',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public static function getAttributeLabel(string $attribute): string
|
||||
@@ -113,10 +118,11 @@ class Member extends Model
|
||||
return "{$this->firstname} {$this->lastname}";
|
||||
}
|
||||
|
||||
public function getRetzienEmailAttribute(): string
|
||||
public function getRetzienEmailAttribute(): ?string
|
||||
{
|
||||
$emails = explode(';', $this->email);
|
||||
return collect($emails)->filter(fn($email) => str_contains($email, '@retzien.fr'))->first();
|
||||
$emails = explode(';', $this->email);
|
||||
|
||||
return collect($emails)->filter(fn ($email) => str_contains($email, '@retzien.fr'))->first();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -152,7 +158,15 @@ class Member extends Model
|
||||
public function hasService(string $serviceIdentifier): bool
|
||||
{
|
||||
$membership = $this->lastMembership();
|
||||
|
||||
return $membership->services()->where('identifier', $serviceIdentifier)->exists();
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
// Member ayant leur dernière adhésion non renouvellée de puis plus d'un mois
|
||||
$lastMembership = $this->lastMembership();
|
||||
|
||||
return $lastMembership->status === 'expired' || $lastMembership->created_at->addMonths(1) < now();
|
||||
}
|
||||
}
|
||||
|
||||
64
app/Models/NotificationTemplate.php
Normal file
64
app/Models/NotificationTemplate.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class NotificationTemplate extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'identifier',
|
||||
'name',
|
||||
'subject',
|
||||
'body',
|
||||
'variables',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'variables' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getAttributeLabel(string $attribute): string
|
||||
{
|
||||
return __('notification_templates.fields.'.$attribute);
|
||||
}
|
||||
|
||||
public static function findByIdentifier(string $identifier): ?self
|
||||
{
|
||||
return self::query()
|
||||
->where('identifier', $identifier)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function renderSubject(array $vars): string
|
||||
{
|
||||
return $this->replacePlaceholders($this->subject, $vars);
|
||||
}
|
||||
|
||||
public function renderBody(array $vars): string
|
||||
{
|
||||
return $this->replacePlaceholders($this->body, $vars);
|
||||
}
|
||||
|
||||
private function replacePlaceholders(string $text, array $vars): string
|
||||
{
|
||||
foreach ($vars as $key => $value) {
|
||||
$text = str_replace('{'.$key.'}', (string) $value, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
48
app/Notifications/SubscriptionExpiredPhase1.php
Normal file
48
app/Notifications/SubscriptionExpiredPhase1.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class SubscriptionExpiredPhase1 extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly NotificationTemplate $template) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$lastMembership = $notifiable->memberships()->latest()->first();
|
||||
|
||||
$vars = [
|
||||
'member_name' => $notifiable->full_name,
|
||||
'expiry_date' => $lastMembership?->end_date ?? '',
|
||||
];
|
||||
|
||||
return (new MailMessage)
|
||||
->subject($this->template->renderSubject($vars))
|
||||
->view('notifications.mail-template', [
|
||||
'body' => $this->template->renderBody($vars),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
69
app/Policies/NotificationTemplatePolicy.php
Normal file
69
app/Policies/NotificationTemplatePolicy.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Foundation\Auth\User as AuthUser;
|
||||
|
||||
class NotificationTemplatePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('ViewAny:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function view(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('View:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function create(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('Create:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function update(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('Update:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function delete(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('Delete:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function restore(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('Restore:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function forceDelete(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('ForceDelete:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function forceDeleteAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('ForceDeleteAny:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function restoreAny(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('RestoreAny:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function replicate(AuthUser $authUser, NotificationTemplate $notificationTemplate): bool
|
||||
{
|
||||
return $authUser->can('Replicate:NotificationTemplate');
|
||||
}
|
||||
|
||||
public function reorder(AuthUser $authUser): bool
|
||||
{
|
||||
return $authUser->can('Reorder:NotificationTemplate');
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,15 @@ class MemberService
|
||||
{
|
||||
/**
|
||||
* Register a new member.
|
||||
* @param array $data
|
||||
* @return Member
|
||||
*/
|
||||
public function registerNewMember(array $data): Member
|
||||
{
|
||||
// Check if the member already exists
|
||||
$member = Member::where('email', $data['email'])->first();
|
||||
|
||||
if (!$member) {
|
||||
if (! $member) {
|
||||
// Create a new member
|
||||
$member = new Member();
|
||||
$member = new Member;
|
||||
$member->status = 'pending';
|
||||
$member->nature = 'physical';
|
||||
$member->group_id = MemberGroup::where('identifier', 'website')->first()->id ?? null;
|
||||
@@ -50,10 +48,7 @@ class MemberService
|
||||
|
||||
]);
|
||||
|
||||
// Notify Admin
|
||||
$admin = Member::where('role', 'admin')->first();
|
||||
event(new MemberRegistered($admin));
|
||||
|
||||
event(new MemberRegistered($member));
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
15
boost.json
Normal file
15
boost.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"agents": [
|
||||
"junie",
|
||||
"claude_code"
|
||||
],
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
"mcp": true,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"wayfinder-development",
|
||||
"inertia-react-development",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"barryvdh/laravel-debugbar": "^3.16",
|
||||
"barryvdh/laravel-ide-helper": "^3.6",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/boost": "^2.1",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
|
||||
202
composer.lock
generated
202
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "8fb1182155fa5b12912041b85f7214d7",
|
||||
"content-hash": "062667ac64956c0d5eaa2b1170382020",
|
||||
"packages": [
|
||||
{
|
||||
"name": "andreia/filament-nord-theme",
|
||||
@@ -9493,6 +9493,145 @@
|
||||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/boost.git",
|
||||
"reference": "1c7d6f44c96937a961056778b9143218b1183302"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/1c7d6f44c96937a961056778b9143218b1183302",
|
||||
"reference": "1c7d6f44c96937a961056778b9143218b1183302",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"illuminate/console": "^11.45.3|^12.41.1",
|
||||
"illuminate/contracts": "^11.45.3|^12.41.1",
|
||||
"illuminate/routing": "^11.45.3|^12.41.1",
|
||||
"illuminate/support": "^11.45.3|^12.41.1",
|
||||
"laravel/mcp": "^0.5.1",
|
||||
"laravel/prompts": "^0.3.10",
|
||||
"laravel/roster": "^0.2.9",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.27.0",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"orchestra/testbench": "^9.15.0|^10.6",
|
||||
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Boost\\BoostServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Boost\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
|
||||
"homepage": "https://github.com/laravel/boost",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/boost/issues",
|
||||
"source": "https://github.com/laravel/boost"
|
||||
},
|
||||
"time": "2026-02-06T10:41:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
"version": "v0.5.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/mcp.git",
|
||||
"reference": "b3327bb75fd2327577281e507e2dbc51649513d6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/b3327bb75fd2327577281e507e2dbc51649513d6",
|
||||
"reference": "b3327bb75fd2327577281e507e2dbc51649513d6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/json-schema": "^12.41.1|^13.0",
|
||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.20",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"pestphp/pest": "^3.8.5|^4.3.2",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.2.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Mcp\\": "src/",
|
||||
"Laravel\\Mcp\\Server\\": "src/Server/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Rapidly build MCP servers for your Laravel applications.",
|
||||
"homepage": "https://github.com/laravel/mcp",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"mcp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/mcp/issues",
|
||||
"source": "https://github.com/laravel/mcp"
|
||||
},
|
||||
"time": "2026-02-05T14:05:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pail",
|
||||
"version": "v1.2.4",
|
||||
@@ -9639,6 +9778,67 @@
|
||||
},
|
||||
"time": "2026-01-05T16:49:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/roster",
|
||||
"version": "v0.2.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/roster.git",
|
||||
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||
"reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/console": "^10.0|^11.0|^12.0",
|
||||
"illuminate/contracts": "^10.0|^11.0|^12.0",
|
||||
"illuminate/routing": "^10.0|^11.0|^12.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||
"php": "^8.1|^8.2",
|
||||
"symfony/yaml": "^6.4|^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.14",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^8.22.0|^9.0|^10.0",
|
||||
"pestphp/pest": "^2.0|^3.0",
|
||||
"phpstan/phpstan": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Roster\\RosterServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Roster\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Detect packages & approaches in use within a Laravel project",
|
||||
"homepage": "https://github.com/laravel/roster",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/roster/issues",
|
||||
"source": "https://github.com/laravel/roster"
|
||||
},
|
||||
"time": "2025-10-20T09:56:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.52.0",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
title: Schéma de Base de Données - Roxane - Le Retzien Libre
|
||||
---
|
||||
erDiagram
|
||||
users ||--o{ members : "a"
|
||||
users ||--o{ memberships : "gère (admin)"
|
||||
users ||--o{ sessions : "a"
|
||||
users ||--o| password_reset_tokens : "peut avoir"
|
||||
|
||||
members }o--|| users : "appartient à"
|
||||
members }o--|| membergroups : "dans le groupe"
|
||||
members ||--o{ memberships : "a des adhésions"
|
||||
|
||||
memberships }o--|| members : "pour"
|
||||
memberships }o--|| packages : "utilise"
|
||||
memberships }o--|| users : "créé par (admin)"
|
||||
|
||||
membergroups ||--o{ members : "contient"
|
||||
|
||||
packages ||--o{ memberships : "inclus dans"
|
||||
|
||||
users {
|
||||
bigint id PK
|
||||
string name
|
||||
string email UK
|
||||
timestamp email_verified_at "nullable"
|
||||
string password
|
||||
string remember_token "nullable"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
password_reset_tokens {
|
||||
string email PK
|
||||
string token
|
||||
timestamp created_at "nullable"
|
||||
}
|
||||
|
||||
sessions {
|
||||
string id PK
|
||||
bigint user_id FK "nullable, indexed"
|
||||
string ip_address "nullable, max 45"
|
||||
text user_agent "nullable"
|
||||
longtext payload
|
||||
integer last_activity "indexed"
|
||||
}
|
||||
|
||||
members {
|
||||
bigint id PK
|
||||
bigint user_id FK
|
||||
string keycloak_id "nullable"
|
||||
string status "valid/pending/expired"
|
||||
string nature "physical/moral"
|
||||
bigint group_id FK
|
||||
string lastname
|
||||
string firstname
|
||||
string email
|
||||
string company "nullable"
|
||||
date date_of_birth "nullable"
|
||||
text address "nullable"
|
||||
string zipcode "nullable"
|
||||
string city "nullable"
|
||||
string country "nullable"
|
||||
string phone1 "nullable"
|
||||
string phone2 "nullable"
|
||||
boolean public_membership "default false"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
membergroups {
|
||||
bigint id PK
|
||||
string identifier UK
|
||||
string name
|
||||
text description "nullable"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
packages {
|
||||
bigint id PK
|
||||
string identifier UK
|
||||
string name
|
||||
text description "nullable"
|
||||
boolean is_active "default true"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
services {
|
||||
bigint id PK
|
||||
string identifier UK
|
||||
string name
|
||||
text description "nullable"
|
||||
string url "nullable"
|
||||
string icon "nullable"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
memberships {
|
||||
bigint id PK
|
||||
bigint member_id FK
|
||||
bigint admin_id FK "créateur"
|
||||
bigint package_id FK
|
||||
date start_date
|
||||
date end_date
|
||||
string status "active/expired/cancelled"
|
||||
decimal amount "montant payé"
|
||||
string payment_status "paid/pending/failed"
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
33
database/factories/NotificationTemplateFactory.php
Normal file
33
database/factories/NotificationTemplateFactory.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<NotificationTemplate>
|
||||
*/
|
||||
class NotificationTemplateFactory extends Factory
|
||||
{
|
||||
protected $model = NotificationTemplate::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'identifier' => $this->faker->unique()->slug(2),
|
||||
'name' => $this->faker->sentence(3),
|
||||
'subject' => $this->faker->sentence(),
|
||||
'body' => $this->faker->paragraph(),
|
||||
'variables' => ['name' => 'Nom'],
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function inactive(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'is_active' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notification_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('identifier')->unique();
|
||||
$table->string('name');
|
||||
$table->string('subject');
|
||||
$table->longText('body');
|
||||
$table->json('variables')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_templates');
|
||||
}
|
||||
};
|
||||
@@ -3,10 +3,9 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\MemberGroup;
|
||||
use App\Models\Package;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use App\Models\Package;
|
||||
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
@@ -36,11 +35,11 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
$websiteGroup = MemberGroup::updateOrCreate([
|
||||
'name' => 'Site Web'
|
||||
'name' => 'Site Web',
|
||||
],
|
||||
[
|
||||
'identifier' => 'website',
|
||||
'description' => 'Groupe d\'utilisateurs provenant du site web.'
|
||||
'description' => 'Groupe d\'utilisateurs provenant du site web.',
|
||||
]);
|
||||
|
||||
// Subscription packages
|
||||
@@ -49,20 +48,20 @@ class DatabaseSeeder extends Seeder
|
||||
'identifier' => 'custom',
|
||||
'name' => 'Sur-mesure',
|
||||
'description' => 'Calcul du nombre de mois restant dans l\'année',
|
||||
'price' => '1.00'
|
||||
'price' => '1.00',
|
||||
],
|
||||
[
|
||||
'identifier' => 'one-year',
|
||||
'name' => 'Un an',
|
||||
'description' => '12 mois à compter de la date de validation de l\'adhésion du membre',
|
||||
'price' => '12.00'
|
||||
'price' => '12.00',
|
||||
],
|
||||
[
|
||||
'identifier' => 'two-years',
|
||||
'name' => 'Deux ans',
|
||||
'description' => '24 mois à compter de la date de validation de l\'adhésion du membre',
|
||||
'price' => '24.00'
|
||||
]
|
||||
'price' => '24.00',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($packages as $package) {
|
||||
@@ -112,7 +111,7 @@ class DatabaseSeeder extends Seeder
|
||||
'description' => 'Service d\'hébergement web',
|
||||
'url' => '#',
|
||||
'icon' => 'database',
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($services as $service) {
|
||||
@@ -126,6 +125,9 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
}
|
||||
|
||||
// Notification templates
|
||||
$this->call(NotificationTemplateSeeder::class);
|
||||
|
||||
// JaneDoe
|
||||
$userTest = User::updateOrCreate([
|
||||
'name' => 'JaneDoe',
|
||||
|
||||
29
database/seeders/NotificationTemplateSeeder.php
Normal file
29
database/seeders/NotificationTemplateSeeder.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class NotificationTemplateSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
NotificationTemplate::updateOrCreate(
|
||||
['identifier' => 'subscription_expired_phase1'],
|
||||
[
|
||||
'name' => 'Adhésion expirée - Phase 1',
|
||||
'subject' => 'Votre adhésion est expirée',
|
||||
'body' => '<p>Bonjour {member_name},</p>'
|
||||
.'<p>Votre adhésion est arrivée à expiration le {expiry_date}.</p>'
|
||||
.'<p>Pour continuer à profiter de nos services, merci de la renouveler.</p>'
|
||||
.'<p>Merci pour votre confiance.</p>',
|
||||
'variables' => [
|
||||
'member_name' => 'Nom complet du membre',
|
||||
'expiry_date' => 'Date de fin d\'adhésion',
|
||||
],
|
||||
'is_active' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ return [
|
||||
'widgets' => [
|
||||
'stats' => [
|
||||
'name' => 'Nouveaux Membres',
|
||||
'description' => 'Nombre de nouveaux membres par jour',
|
||||
'description' => 'Nombre de nouveaux membres par an',
|
||||
]
|
||||
]
|
||||
],
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
14
lang/fr/notification_templates.php
Normal file
14
lang/fr/notification_templates.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'fields' => [
|
||||
'singular_name' => 'Template Mail',
|
||||
'plural_name' => 'Templates Mail',
|
||||
'identifier' => 'Identifiant',
|
||||
'name' => 'Nom',
|
||||
'subject' => 'Objet',
|
||||
'body' => 'Contenu',
|
||||
'variables' => 'Variables disponibles',
|
||||
'is_active' => 'Actif',
|
||||
],
|
||||
];
|
||||
83
resources/js/actions/App/Filament/Pages/Synchronisations.ts
Normal file
83
resources/js/actions/App/Filament/Pages/Synchronisations.ts
Normal file
@@ -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
|
||||
7
resources/js/actions/App/Filament/Pages/index.ts
Normal file
7
resources/js/actions/App/Filament/Pages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Synchronisations from './Synchronisations'
|
||||
|
||||
const Pages = {
|
||||
Synchronisations: Object.assign(Synchronisations, Synchronisations),
|
||||
}
|
||||
|
||||
export default Pages
|
||||
@@ -0,0 +1,83 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
const CreateNotificationTemplate = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: CreateNotificationTemplate.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
CreateNotificationTemplate.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates/create',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
CreateNotificationTemplate.url = (options?: RouteQueryOptions) => {
|
||||
return CreateNotificationTemplate.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
CreateNotificationTemplate.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: CreateNotificationTemplate.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
CreateNotificationTemplate.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: CreateNotificationTemplate.url(options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
const CreateNotificationTemplateForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: CreateNotificationTemplate.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
CreateNotificationTemplateForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: CreateNotificationTemplate.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
CreateNotificationTemplateForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: CreateNotificationTemplate.url({
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
CreateNotificationTemplate.form = CreateNotificationTemplateForm
|
||||
|
||||
export default CreateNotificationTemplate
|
||||
@@ -0,0 +1,101 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
const EditNotificationTemplate = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: EditNotificationTemplate.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
EditNotificationTemplate.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates/{record}/edit',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
EditNotificationTemplate.url = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions) => {
|
||||
if (typeof args === 'string' || typeof args === 'number') {
|
||||
args = { record: args }
|
||||
}
|
||||
|
||||
if (Array.isArray(args)) {
|
||||
args = {
|
||||
record: args[0],
|
||||
}
|
||||
}
|
||||
|
||||
args = applyUrlDefaults(args)
|
||||
|
||||
const parsedArgs = {
|
||||
record: args.record,
|
||||
}
|
||||
|
||||
return EditNotificationTemplate.definition.url
|
||||
.replace('{record}', parsedArgs.record.toString())
|
||||
.replace(/\/+$/, '') + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
EditNotificationTemplate.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: EditNotificationTemplate.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
EditNotificationTemplate.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: EditNotificationTemplate.url(args, options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
const EditNotificationTemplateForm = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: EditNotificationTemplate.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
EditNotificationTemplateForm.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: EditNotificationTemplate.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
EditNotificationTemplateForm.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: EditNotificationTemplate.url(args, {
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
EditNotificationTemplate.form = EditNotificationTemplateForm
|
||||
|
||||
export default EditNotificationTemplate
|
||||
@@ -0,0 +1,83 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
const ListNotificationTemplates = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: ListNotificationTemplates.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
ListNotificationTemplates.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
ListNotificationTemplates.url = (options?: RouteQueryOptions) => {
|
||||
return ListNotificationTemplates.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
ListNotificationTemplates.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: ListNotificationTemplates.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
ListNotificationTemplates.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: ListNotificationTemplates.url(options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
const ListNotificationTemplatesForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: ListNotificationTemplates.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
ListNotificationTemplatesForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: ListNotificationTemplates.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
ListNotificationTemplatesForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: ListNotificationTemplates.url({
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
ListNotificationTemplates.form = ListNotificationTemplatesForm
|
||||
|
||||
export default ListNotificationTemplates
|
||||
@@ -0,0 +1,11 @@
|
||||
import ListNotificationTemplates from './ListNotificationTemplates'
|
||||
import CreateNotificationTemplate from './CreateNotificationTemplate'
|
||||
import EditNotificationTemplate from './EditNotificationTemplate'
|
||||
|
||||
const Pages = {
|
||||
ListNotificationTemplates: Object.assign(ListNotificationTemplates, ListNotificationTemplates),
|
||||
CreateNotificationTemplate: Object.assign(CreateNotificationTemplate, CreateNotificationTemplate),
|
||||
EditNotificationTemplate: Object.assign(EditNotificationTemplate, EditNotificationTemplate),
|
||||
}
|
||||
|
||||
export default Pages
|
||||
@@ -0,0 +1,7 @@
|
||||
import Pages from './Pages'
|
||||
|
||||
const NotificationTemplates = {
|
||||
Pages: Object.assign(Pages, Pages),
|
||||
}
|
||||
|
||||
export default NotificationTemplates
|
||||
@@ -1,6 +1,7 @@
|
||||
import MemberGroups from './MemberGroups'
|
||||
import Members from './Members'
|
||||
import Memberships from './Memberships'
|
||||
import NotificationTemplates from './NotificationTemplates'
|
||||
import Packages from './Packages'
|
||||
import Services from './Services'
|
||||
import Users from './Users'
|
||||
@@ -9,6 +10,7 @@ const Resources = {
|
||||
MemberGroups: Object.assign(MemberGroups, MemberGroups),
|
||||
Members: Object.assign(Members, Members),
|
||||
Memberships: Object.assign(Memberships, Memberships),
|
||||
NotificationTemplates: Object.assign(NotificationTemplates, NotificationTemplates),
|
||||
Packages: Object.assign(Packages, Packages),
|
||||
Services: Object.assign(Services, Services),
|
||||
Users: Object.assign(Users, Users),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Pages from './Pages'
|
||||
import Resources from './Resources'
|
||||
|
||||
const Filament = {
|
||||
Pages: Object.assign(Pages, Pages),
|
||||
Resources: Object.assign(Resources, Resources),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
||||
import AppearanceToggleDropdown from './appearance-dropdown';
|
||||
import AppLogo from './app-logo';
|
||||
import AppLogoIcon from './app-logo-icon';
|
||||
|
||||
@@ -238,6 +239,7 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AppearanceToggleDropdown />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Button} from "@/components/ui/button";
|
||||
import type {SharedData} from "@/types";
|
||||
import {useMobileNavigation} from "@/hooks/use-mobile-navigation";
|
||||
import {LogOut} from "lucide-react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
export default function NavGuestLayout() {
|
||||
const {auth} = usePage<SharedData>().props;
|
||||
@@ -15,6 +16,16 @@ export default function NavGuestLayout() {
|
||||
router.flushAll();
|
||||
};
|
||||
|
||||
const [dark, setDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}, [dark]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -90,6 +101,12 @@ export default function NavGuestLayout() {
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<button
|
||||
onClick={() => setDark(!dark)}
|
||||
className="absolute top-4 right-4 px-3 py-1 rounded-xl border border-gray-400 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
{dark ? "☀️ Mode clair" : "🌙 Mode sombre"}
|
||||
</button>
|
||||
</header>
|
||||
</>
|
||||
)
|
||||
|
||||
57
resources/js/routes/boost/index.ts
Normal file
57
resources/js/routes/boost/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition } from './../../wayfinder'
|
||||
/**
|
||||
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
|
||||
* @route '/_boost/browser-logs'
|
||||
*/
|
||||
export const browserLogs = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
url: browserLogs.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
browserLogs.definition = {
|
||||
methods: ["post"],
|
||||
url: '/_boost/browser-logs',
|
||||
} satisfies RouteDefinition<["post"]>
|
||||
|
||||
/**
|
||||
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
|
||||
* @route '/_boost/browser-logs'
|
||||
*/
|
||||
browserLogs.url = (options?: RouteQueryOptions) => {
|
||||
return browserLogs.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
|
||||
* @route '/_boost/browser-logs'
|
||||
*/
|
||||
browserLogs.post = (options?: RouteQueryOptions): RouteDefinition<'post'> => ({
|
||||
url: browserLogs.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
|
||||
* @route '/_boost/browser-logs'
|
||||
*/
|
||||
const browserLogsForm = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
action: browserLogs.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see vendor/laravel/boost/src/BoostServiceProvider.php:117
|
||||
* @route '/_boost/browser-logs'
|
||||
*/
|
||||
browserLogsForm.post = (options?: RouteQueryOptions): RouteFormDefinition<'post'> => ({
|
||||
action: browserLogs.url(options),
|
||||
method: 'post',
|
||||
})
|
||||
|
||||
browserLogs.form = browserLogsForm
|
||||
|
||||
const boost = {
|
||||
browserLogs: Object.assign(browserLogs, browserLogs),
|
||||
}
|
||||
|
||||
export default boost
|
||||
@@ -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
|
||||
@@ -1,6 +1,7 @@
|
||||
import memberGroups from './member-groups'
|
||||
import members from './members'
|
||||
import memberships from './memberships'
|
||||
import notificationTemplates from './notification-templates'
|
||||
import packages from './packages'
|
||||
import services from './services'
|
||||
import users from './users'
|
||||
@@ -10,6 +11,7 @@ const resources = {
|
||||
memberGroups: Object.assign(memberGroups, memberGroups),
|
||||
members: Object.assign(members, members),
|
||||
memberships: Object.assign(memberships, memberships),
|
||||
notificationTemplates: Object.assign(notificationTemplates, notificationTemplates),
|
||||
packages: Object.assign(packages, packages),
|
||||
services: Object.assign(services, services),
|
||||
users: Object.assign(users, users),
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { queryParams, type RouteQueryOptions, type RouteDefinition, type RouteFormDefinition, applyUrlDefaults } from './../../../../../wayfinder'
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
export const index = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
index.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
index.url = (options?: RouteQueryOptions) => {
|
||||
return index.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
index.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
index.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: index.url(options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
const indexForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
indexForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\ListNotificationTemplates::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/ListNotificationTemplates.php:7
|
||||
* @route '/admin/notification-templates'
|
||||
*/
|
||||
indexForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: index.url({
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
index.form = indexForm
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
export const create = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: create.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
create.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates/create',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
create.url = (options?: RouteQueryOptions) => {
|
||||
return create.definition.url + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
create.get = (options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: create.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
create.head = (options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: create.url(options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
const createForm = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: create.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
createForm.get = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: create.url(options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\CreateNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/CreateNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/create'
|
||||
*/
|
||||
createForm.head = (options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: create.url({
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
create.form = createForm
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
export const edit = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: edit.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
edit.definition = {
|
||||
methods: ["get","head"],
|
||||
url: '/admin/notification-templates/{record}/edit',
|
||||
} satisfies RouteDefinition<["get","head"]>
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
edit.url = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions) => {
|
||||
if (typeof args === 'string' || typeof args === 'number') {
|
||||
args = { record: args }
|
||||
}
|
||||
|
||||
if (Array.isArray(args)) {
|
||||
args = {
|
||||
record: args[0],
|
||||
}
|
||||
}
|
||||
|
||||
args = applyUrlDefaults(args)
|
||||
|
||||
const parsedArgs = {
|
||||
record: args.record,
|
||||
}
|
||||
|
||||
return edit.definition.url
|
||||
.replace('{record}', parsedArgs.record.toString())
|
||||
.replace(/\/+$/, '') + queryParams(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
edit.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'get'> => ({
|
||||
url: edit.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
edit.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteDefinition<'head'> => ({
|
||||
url: edit.url(args, options),
|
||||
method: 'head',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
const editForm = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: edit.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
editForm.get = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: edit.url(args, options),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
/**
|
||||
* @see \App\Filament\Resources\NotificationTemplates\Pages\EditNotificationTemplate::__invoke
|
||||
* @see app/Filament/Resources/NotificationTemplates/Pages/EditNotificationTemplate.php:7
|
||||
* @route '/admin/notification-templates/{record}/edit'
|
||||
*/
|
||||
editForm.head = (args: { record: string | number } | [record: string | number ] | string | number, options?: RouteQueryOptions): RouteFormDefinition<'get'> => ({
|
||||
action: edit.url(args, {
|
||||
[options?.mergeQuery ? 'mergeQuery' : 'query']: {
|
||||
_method: 'HEAD',
|
||||
...(options?.query ?? options?.mergeQuery ?? {}),
|
||||
}
|
||||
}),
|
||||
method: 'get',
|
||||
})
|
||||
|
||||
edit.form = editForm
|
||||
|
||||
const notificationTemplates = {
|
||||
index: Object.assign(index, index),
|
||||
create: Object.assign(create, create),
|
||||
edit: Object.assign(edit, edit),
|
||||
}
|
||||
|
||||
export default notificationTemplates
|
||||
@@ -0,0 +1,10 @@
|
||||
<span class="flex items-center gap-2">
|
||||
{{ $label }}
|
||||
@if(in_array($status['status'], ['pending', 'running']))
|
||||
<x-filament::loading-indicator class="h-4 w-4 text-primary-500" />
|
||||
@elseif($status['status'] === 'completed')
|
||||
<x-filament::icon icon="heroicon-o-check-circle" class="h-4 w-4 text-success-500" />
|
||||
@elseif($status['status'] === 'failed')
|
||||
<x-filament::icon icon="heroicon-o-x-circle" class="h-4 w-4 text-danger-500" />
|
||||
@endif
|
||||
</span>
|
||||
@@ -0,0 +1,12 @@
|
||||
@if($status['status'] === 'pending')
|
||||
<p class="text-sm text-warning-600 dark:text-warning-400">En attente dans la file d'exécution...</p>
|
||||
@elseif($status['status'] === 'running')
|
||||
<p class="text-sm text-primary-600 dark:text-primary-400">Exécution en cours...</p>
|
||||
@elseif(in_array($status['status'], ['completed', 'failed']) && $status['output'])
|
||||
<div class="rounded-lg bg-gray-50 p-2 dark:bg-gray-900">
|
||||
<pre class="max-h-28 overflow-auto whitespace-pre-wrap text-xs {{ $status['status'] === 'failed' ? 'text-danger-600' : 'text-gray-700 dark:text-gray-300' }}">{{ $status['output'] }}</pre>
|
||||
</div>
|
||||
@if($status['finished_at'])
|
||||
<p class="text-xs text-gray-400">Terminé à {{ $status['finished_at'] }}</p>
|
||||
@endif
|
||||
@endif
|
||||
109
resources/views/filament/pages/synchronisations.blade.php
Normal file
109
resources/views/filament/pages/synchronisations.blade.php
Normal file
@@ -0,0 +1,109 @@
|
||||
@vite('resources/css/backend.css')
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
@if($this->hasRunningCommands()) wire:poll.5s @endif
|
||||
>
|
||||
@php
|
||||
$dolibarr = $this->getCommandStatus('dolibarr');
|
||||
$expired = $this->getCommandStatus('cleanup_expired');
|
||||
$ispMail = $this->getCommandStatus('ispconfig_mail');
|
||||
$ispWeb = $this->getCommandStatus('ispconfig_web');
|
||||
$nextcloud = $this->getCommandStatus('nextcloud');
|
||||
$services = $this->getCommandStatus('services');
|
||||
@endphp
|
||||
|
||||
{{-- Dolibarr --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'Dolibarr', 'status' => $dolibarr])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Importe les membres et cotisations depuis Dolibarr.
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $dolibarr])
|
||||
|
||||
{{ $this->getAction('syncDolibarr') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Membres expirés --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'Membres expirés', 'status' => $expired])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Désactive les membres expirés dans Dolibarr, ISPConfig et Nextcloud.
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $expired])
|
||||
|
||||
{{ $this->getAction('cleanupExpired') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- ISPConfig Mail --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'ISPConfig Mail', 'status' => $ispMail])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Lie les membres à leurs comptes mail ISPConfig (@retzien.fr).
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $ispMail])
|
||||
|
||||
{{ $this->getAction('syncISPConfigMail') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- ISPConfig Web --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'ISPConfig Web', 'status' => $ispWeb])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Lie les membres à leurs comptes d'hébergement web.
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $ispWeb])
|
||||
|
||||
{{ $this->getAction('syncISPConfigWeb') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Nextcloud --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'Nextcloud', 'status' => $nextcloud])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Lie les membres à leurs comptes Nextcloud.
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $nextcloud])
|
||||
|
||||
{{ $this->getAction('syncNextcloud') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
{{-- Services membres --}}
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
@include('filament.pages.partials.sync-heading', ['label' => 'Services membres', 'status' => $services])
|
||||
</x-slot>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Synchronise les services associés aux membres actifs.
|
||||
</p>
|
||||
@include('filament.pages.partials.sync-status', ['status' => $services])
|
||||
|
||||
{{ $this->getAction('syncServices') }}
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</x-filament-panels::page>
|
||||
3
resources/views/notifications/mail-template.blade.php
Normal file
3
resources/views/notifications/mail-template.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<x-mail::message>
|
||||
{!! $body !!}
|
||||
</x-mail::message>
|
||||
77
tests/Feature/NotificationTemplateTest.php
Normal file
77
tests/Feature/NotificationTemplateTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationTemplateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_find_by_identifier_returns_active_template(): void
|
||||
{
|
||||
$template = NotificationTemplate::factory()->create([
|
||||
'identifier' => 'test_template',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$found = NotificationTemplate::findByIdentifier('test_template');
|
||||
|
||||
$this->assertNotNull($found);
|
||||
$this->assertEquals($template->id, $found->id);
|
||||
}
|
||||
|
||||
public function test_find_by_identifier_returns_null_for_inactive_template(): void
|
||||
{
|
||||
NotificationTemplate::factory()->inactive()->create([
|
||||
'identifier' => 'inactive_template',
|
||||
]);
|
||||
|
||||
$found = NotificationTemplate::findByIdentifier('inactive_template');
|
||||
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
public function test_render_subject_replaces_placeholders(): void
|
||||
{
|
||||
$template = NotificationTemplate::factory()->create([
|
||||
'subject' => 'Bonjour {member_name}, votre adhésion expire le {expiry_date}',
|
||||
]);
|
||||
|
||||
$result = $template->renderSubject([
|
||||
'member_name' => 'Jean Dupont',
|
||||
'expiry_date' => '2026-01-31',
|
||||
]);
|
||||
|
||||
$this->assertEquals('Bonjour Jean Dupont, votre adhésion expire le 2026-01-31', $result);
|
||||
}
|
||||
|
||||
public function test_render_body_replaces_placeholders(): void
|
||||
{
|
||||
$template = NotificationTemplate::factory()->create([
|
||||
'body' => '<p>Bonjour {member_name}</p><p>Expiration : {expiry_date}</p>',
|
||||
]);
|
||||
|
||||
$result = $template->renderBody([
|
||||
'member_name' => 'Marie Martin',
|
||||
'expiry_date' => '2026-06-15',
|
||||
]);
|
||||
|
||||
$this->assertEquals('<p>Bonjour Marie Martin</p><p>Expiration : 2026-06-15</p>', $result);
|
||||
}
|
||||
|
||||
public function test_missing_placeholder_is_left_intact(): void
|
||||
{
|
||||
$template = NotificationTemplate::factory()->create([
|
||||
'subject' => 'Bonjour {member_name}, date : {expiry_date}',
|
||||
]);
|
||||
|
||||
$result = $template->renderSubject([
|
||||
'member_name' => 'Jean Dupont',
|
||||
]);
|
||||
|
||||
$this->assertEquals('Bonjour Jean Dupont, date : {expiry_date}', $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\SendSubscriptionExpiredPhase1Notifications;
|
||||
use App\Models\Member;
|
||||
use App\Models\Membership;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\Package;
|
||||
use App\Notifications\SubscriptionExpiredPhase1;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendSubscriptionExpiredPhase1NotificationsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createMemberWithExpiredMembership(): Member
|
||||
{
|
||||
$package = Package::create([
|
||||
'identifier' => 'test-package',
|
||||
'name' => 'Test Package',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$member = Member::create([
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'firstname' => fake()->firstName(),
|
||||
'lastname' => fake()->lastName(),
|
||||
'status' => 'valid',
|
||||
'nature' => 'physical',
|
||||
]);
|
||||
|
||||
Membership::create([
|
||||
'member_id' => $member->id,
|
||||
'package_id' => $package->id,
|
||||
'status' => 'expired',
|
||||
'end_date' => '2025-12-31',
|
||||
'amount' => 12.00,
|
||||
'payment_status' => 'paid',
|
||||
]);
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
public function test_job_sends_notifications_to_expired_members(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
NotificationTemplate::factory()->create([
|
||||
'identifier' => 'subscription_expired_phase1',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$expiredMember = $this->createMemberWithExpiredMembership();
|
||||
|
||||
(new SendSubscriptionExpiredPhase1Notifications)->handle();
|
||||
|
||||
Notification::assertSentTo($expiredMember, SubscriptionExpiredPhase1::class);
|
||||
}
|
||||
|
||||
public function test_job_does_nothing_when_template_is_inactive(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
NotificationTemplate::factory()->inactive()->create([
|
||||
'identifier' => 'subscription_expired_phase1',
|
||||
]);
|
||||
|
||||
$this->createMemberWithExpiredMembership();
|
||||
|
||||
(new SendSubscriptionExpiredPhase1Notifications)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_job_does_nothing_when_template_does_not_exist(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$this->createMemberWithExpiredMembership();
|
||||
|
||||
(new SendSubscriptionExpiredPhase1Notifications)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user