Dans le [Module 5], nous avons blindé la sécurité de notre panneau d’administration grâce aux rôles RBAC, aux Policies Laravel 13 et au 2FA natif.
Pour autant, un back-office ne sert pas uniquement à saisir des données : c’est avant tout la tour de contrôle de l’entreprise. Les dirigeants et les responsables d’exploitation ont besoin de visualiser immédiatement la santé financière de l’activité, de repérer les goulots d’étranglement logistiques et d’extraire des rapports consolidés en un clic.
Dans ce module, nous allons transformer EquipFlow en outil d’aide à la décision :
- Un tableau de bord de synthèse avec des widgets statistiques réactifs (MRR, taux d’occupation du parc).
- Un graphique de tendance (Chart.js) pour visualiser l’évolution du chiffre d’affaires sur l’année.
- Un moteur d’export de données en tâche de fond (Queue) pour générer des fichiers CSV volumineux sans saturer la mémoire du serveur.
- Un bordereau PDF prêt pour l’impression via une vue Blade dédiée.
Des Widgets de statistiques réactifs
Créons un ensemble d’indicateurs clés (KPIs) qui s’affichent en tête de notre tableau de bord.
Générez le widget :
php artisan make:filament-widget EquipFlowStatsOverview --stats-overview
Ouvrez app/Filament/Widgets/EquipFlowStatsOverview.php :
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Enums\AssignmentStatus;
use App\Models\Assignment;
use App\Models\Equipment;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class EquipFlowStatsOverview extends BaseWidget
{
// Rafraîchissement automatique toutes les 30 secondes
protected static ?string $pollingInterval = '30s';
protected function getStats(): array
{
// Calcul du chiffre d'affaires mensuel récurrent (MRR)
$mrr = (float) Assignment::where('status', AssignmentStatus::Active->value)
->sum('monthly_cost');
// Parc total et équipements actuellement en clientèle
$totalEquipment = Equipment::count();
$assignedEquipment = Assignment::where('status', AssignmentStatus::Active->value)->count();
$occupancyRate = $totalEquipment > 0
? round(($assignedEquipment / $totalEquipment) * 100, 1)
: 0;
// Nombre de retours en attente
$pendingReturns = Assignment::where('status', AssignmentStatus::PendingReturn->value)->count();
return [
Stat::make('Revenu Récurrent Mensuel (MRR)', number_format($mrr, 2, ',', ' ') . ' €')
->description('Contrats actifs ce mois-ci')
->descriptionIcon('heroicon-m-banknotes')
->chart([2100, 2400, 2900, 3100, 3800, 4200, (int) $mrr])
->color('success'),
Stat::make('Taux d’occupation du parc', "{$occupancyRate} %")
->description("{$assignedEquipment} matériels sur {$totalEquipment}")
->descriptionIcon('heroicon-m-arrow-trending-up')
->color($occupancyRate > 75 ? 'primary' : 'warning'),
Stat::make('Retours en attente', (string) $pendingReturns)
->description('Matériels à contrôler en atelier')
->descriptionIcon('heroicon-m-clock')
->color($pendingReturns > 0 ? 'danger' : 'gray'),
];
}
}
Graphique d’évolution : Revenus générés sur 12 mois
Intégrons maintenant un graphique linéaire pour mesurer la croissance financière de la flotte de matériel.
Générez le widget graphique :
php artisan make:filament-widget RevenueChart --chart
Dans app/Filament/Widgets/RevenueChart.php :
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Enums\AssignmentStatus;
use App\Models\Assignment;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class RevenueChart extends ChartWidget
{
protected static ?string $heading = 'Trajectoire des revenus (12 derniers mois)';
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected function getData(): array
{
$months = collect(range(11, 0))->map(fn ($i) => Carbon::now()->subMonths($i));
$data = $months->map(function (Carbon $date) {
return Assignment::where('status', AssignmentStatus::Active->value)
->where('assigned_at', '<=', $date->endOfMonth())
->sum('monthly_cost');
});
return [
'datasets' => [
[
'label' => 'Revenus facturés (€)',
'data' => $data->toArray(),
'borderColor' => '#f59e0b', // Ambre Filament
'backgroundColor' => 'rgba(245, 158, 11, 0.1)',
'fill' => true,
'tension' => 0.3,
],
],
'labels' => $months->map(fn (Carbon $date) => $date->translatedFormat('M Y'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}
Export CSV massif traité en tâche de fond (Queues)
Lorsque votre base contiendra plusieurs milliers de contrats, générer un fichier d’export de manière synchrone provoquera immanquablement une erreur de dépassement mémoire (Memory Exhausted) ou d’expiration de temps de requête (HTTP Timeout).
Filament 5 intègre un système d’export natif qui segmente les requêtes et prépare les fichiers en arrière-plan via les Queues de Laravel.
Étape A : Génération de l’Exporter
php artisan make:filament-exporter Assignment
Cette commande crée la classe app/Filament/Exports/AssignmentExporter.php. Configurons les colonnes à extraire :
declare(strict_types=1);
namespace App\Filament\Exports;
use App\Models\Assignment;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
class AssignmentExporter extends Exporter
{
protected static ?string $model = Assignment::class;
public static function getColumns(): array
{
return [
ExportColumn::make('id')->label('Référence'),
ExportColumn::make('client.name')->label('Client'),
ExportColumn::make('client.email')->label('Email Contact'),
ExportColumn::make('equipment.name')->label('Équipement'),
ExportColumn::make('equipment.serial_number')->label('Numéro de Série'),
ExportColumn::make('status')->label('Statut'),
ExportColumn::make('monthly_cost')->label('Loyer HT'),
ExportColumn::make('assigned_at')->label('Date Début'),
ExportColumn::make('returned_at')->label('Date Fin'),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
$body = 'Votre export des contrats est terminé. ' . number_format($export->successful_rows) . ' lignes exportées.';
if ($failedRowsCount = $export->getFailedRowsCount()) {
$body .= ' ' . number_format($failedRowsCount) . ' lignes ont échoué.';
}
return $body;
}
}
Étape B : Branchement de l’Action d’export sur la table
Dans app/Filament/Resources/AssignmentResource.php, ajoutez le bouton d’export dans l’en-tête de la table :
use App\Filament\Exports\AssignmentExporter;
use Filament\Tables\Actions\ExportAction;
// Dans la méthode table(Table $table) :
->headerActions([
ExportAction::make()
->exporter(AssignmentExporter::class)
->label('Exporter en CSV / Excel')
->icon('heroicon-o-arrow-down-tray'),
])
Dès qu’un administrateur lance un export, Laravel délègue la tâche aux workers de queue. Une fois le traitement achevé, l’administrateur reçoit une notification dans son interface avec un lien sécurisé pour télécharger son fichier.
Génération de bordereau d’assignation au format PDF
Pour fournir au client un bon de mise à disposition signé lors de la remise de son matériel, nous créons une action unitaire téléchargeable depuis la table.
Étape A : Création de la vue Blade du bordereau
Créez le template resources/views/documents/assignment-receipt.blade.php :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Bon d'affectation #{{ $assignment->id }}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1e293b; padding: 40px; }
.header { border-bottom: 2px solid #f59e0b; padding-bottom: 15px; margin-bottom: 30px; }
.header h1 { margin: 0; font-size: 24px; color: #0f172a; }
.meta { display: flex; justify-content: space-between; margin-bottom: 30px; }
.block { width: 48%; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 15px; }
.block h3 { margin-top: 0; font-size: 14px; text-transform: uppercase; color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 10px 12px; text-align: left; font-size: 13px; }
th { background-color: #f1f5f9; font-weight: 600; }
.signatures { margin-top: 60px; display: flex; justify-content: space-between; }
.signature-box { width: 45%; border-top: 1px dashed #94a3b8; padding-top: 10px; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>EquipFlow — Bon de mise à disposition de matériel</h1>
<p style="font-size: 12px; color: #64748b; margin-top: 5px;">Réf. Document : DOC-{{ str_pad((string)$assignment->id, 5, '0', STR_PAD_LEFT) }}</p>
</div>
<div class="meta">
<div class="block">
<h3>Bénéficiaire</h3>
<strong>{{ $assignment->client->name }}</strong><br>
Email : {{ $assignment->client->email }}<br>
Tél : {{ $assignment->client->phone ?? 'Non renseigné' }}<br>
N° TVA : {{ $assignment->client->vat_number ?? 'N/A' }}
</div>
<div class="block">
<h3>Modalités du contrat</h3>
Date d'affectation : <strong>{{ $assignment->assigned_at->format('d/m/Y') }}</strong><br>
Statut actuel : {{ $assignment->status->label() }}<br>
Redevance mensuelle : <strong>{{ number_format((float) $assignment->monthly_cost, 2, ',', ' ') }} € HT</strong>
</div>
</div>
<table>
<thead>
<tr>
<th>Désignation de l'équipement</th>
<th>Catégorie</th>
<th>Numéro de Série</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ $assignment->equipment->name }}</td>
<td>{{ $assignment->equipment->category->label() }}</td>
<td><code>{{ $assignment->equipment->serial_number }}</code></td>
</tr>
</tbody>
</table>
@if($assignment->notes)
<div style="margin-top: 20px; font-size: 12px; color: #475569;">
<strong>Observations relatives à l'état du bien :</strong><br>
{{ $assignment->notes }}
</div>
@endif
<div class="signatures">
<div class="signature-box">
Pour l'entreprise (Remis par) :<br><br><br>
Date et signature :
</div>
<div class="signature-box">
Pour le client locataire (Bon pour accord) :<br><br><br>
Date et signature :
</div>
</div>
</body>
</html>
Étape B : Branchement de l’action de téléchargement sur chaque ligne
Dans la méthode table() de app/Filament/Resources/AssignmentResource.php :
use App\Models\Assignment;
use Filament\Tables\Actions\Action;
// Dans le tableau ->actions([...]) :
Action::make('download_receipt')
->label('Bordereau')
->icon('heroicon-o-document-arrow-down')
->color('gray')
->action(function (Assignment $record) {
$html = view('documents.assignment-receipt', [
'assignment' => $record,
])->render();
return response()->streamDownload(
fn () => print($html),
"bordereau-equipement-{$record->id}.html"
);
}),
Résumé du Module 6
Avec ce sixième module, notre outil offre une valeur opérationnelle immédiate :
- Un tableau de bord analytique qui calcule les métriques clés en direct (MRR, taux d’occupation).
- Un graphique dynamique suivant la courbe de facturation sur un an.
- Un système d’export asynchrone sécurisé capable de digérer des bases volumineuses sans ralentir les utilisateurs.
- Une solution de bordereau imprimable prête pour la remise physique des équipements.
Dans le Module 7, nous nous attaquerons à l’assurance qualité : Stratégie de tests automatisés avec Pest PHP (tester nos règles d’intégrité métier, les permissions d’accès et les interactions Filament sans lancer de navigateur lourd).



