Compare commits

...

10 Commits

Author SHA1 Message Date
72721adaff chore: ignore agent configuration files
All checks were successful
Deploy Roxane to Preprod / deploy (push) Successful in 1m30s
2026-03-24 15:01:30 +01:00
ab3a27d462 fix: clean git ignore agents 2026-03-24 14:53:48 +01:00
1790214fff feat(add services on membership page) 2026-03-16 15:20:49 +01:00
c3b64e4bb9 feat(Synchro pages test) 2026-02-20 15:36:48 +01:00
74c030de65 wip(Front dark mode) 2026-02-16 17:22:01 +01:00
6e73c82787 feat(Mail template & Membership relationship) 2026-02-16 14:16:52 +01:00
45920c083e test(Claude context: database structure) 2026-02-09 18:46:38 +01:00
d8500a251d test(Claude context: project structure) 2026-02-09 18:40:44 +01:00
e88d12e99c feat(Laravel Boost & Claude configuration) 2026-02-09 18:22:14 +01:00
3389316aef wip(Notification system) 2026-02-08 21:59:16 +01:00
58 changed files with 2675 additions and 321 deletions

9
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

193
PROJECT_STRUCTURE.md Normal file
View 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 |

View File

@@ -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

View File

@@ -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) {}
}

View File

@@ -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) {}
}

View File

@@ -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;
}
}

View 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'));
}
}

View File

@@ -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,
];
}
}

View File

@@ -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(),
]),
]);
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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(),
]),
]);
}
}

View File

@@ -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(),
]),
]);
}
}

View 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());
}
}

View 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));
}
});
}
}

View File

@@ -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
{
//
}
}

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View 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;
}
}

View 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 [];
}
}

View 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');
}
}

View File

@@ -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
View 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"
]
}

View File

@@ -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
View File

@@ -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",

View File

@@ -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
}

View 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,
]);
}
}

View File

@@ -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');
}
};

View File

@@ -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',

View 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,
]
);
}
}

View File

@@ -35,7 +35,7 @@ return [
'widgets' => [
'stats' => [
'name' => 'Nouveaux Membres',
'description' => 'Nombre de nouveaux membres par jour',
'description' => 'Nombre de nouveaux membres par an',
]
]
],

View File

@@ -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' => [

View 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',
],
];

View 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

View File

@@ -0,0 +1,7 @@
import Synchronisations from './Synchronisations'
const Pages = {
Synchronisations: Object.assign(Synchronisations, Synchronisations),
}
export default Pages

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,7 @@
import Pages from './Pages'
const NotificationTemplates = {
Pages: Object.assign(Pages, Pages),
}
export default NotificationTemplates

View File

@@ -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),

View File

@@ -1,6 +1,8 @@
import Pages from './Pages'
import Resources from './Resources'
const Filament = {
Pages: Object.assign(Pages, Pages),
Resources: Object.assign(Resources, Resources),
}

View File

@@ -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

View File

@@ -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>
</>
)

View 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

View File

@@ -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

View File

@@ -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),

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -0,0 +1,3 @@
<x-mail::message>
{!! $body !!}
</x-mail::message>

View 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);
}
}

View File

@@ -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();
}
}