diff --git a/src/app/Filament/Resources/AuditResource.php b/src/app/Filament/Resources/AuditResource.php new file mode 100644 index 0000000..5d5d5b4 --- /dev/null +++ b/src/app/Filament/Resources/AuditResource.php @@ -0,0 +1,231 @@ +user()?->can('audit.view') ?? false; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } + + public static function form(Form $form): Form + { + return $form->schema([]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('created_at') + ->label('Date/Time') + ->dateTime('M j, Y H:i:s') + ->sortable(), + + Tables\Columns\TextColumn::make('user.name') + ->label('User') + ->default('System') + ->searchable() + ->sortable(), + + Tables\Columns\BadgeColumn::make('event') + ->label('Action') + ->colors([ + 'success' => 'created', + 'warning' => 'updated', + 'danger' => 'deleted', + 'info' => 'restored', + ]) + ->formatStateUsing(fn (string $state): string => ucfirst($state)), + + Tables\Columns\TextColumn::make('auditable_type') + ->label('Model') + ->formatStateUsing(fn (string $state): string => class_basename($state)) + ->sortable(), + + Tables\Columns\TextColumn::make('auditable_id') + ->label('Record ID') + ->sortable(), + + Tables\Columns\TextColumn::make('ip_address') + ->label('IP Address') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('url') + ->label('URL') + ->limit(30) + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + Tables\Filters\SelectFilter::make('event') + ->label('Action') + ->options([ + 'created' => 'Created', + 'updated' => 'Updated', + 'deleted' => 'Deleted', + 'restored' => 'Restored', + ]), + + Tables\Filters\SelectFilter::make('user_id') + ->label('User') + ->relationship('user', 'name') + ->searchable() + ->preload(), + + Tables\Filters\SelectFilter::make('auditable_type') + ->label('Model') + ->options(fn () => Audit::query() + ->distinct() + ->pluck('auditable_type') + ->mapWithKeys(fn ($type) => [$type => class_basename($type)]) + ->toArray() + ), + + Tables\Filters\Filter::make('created_at') + ->form([ + Forms\Components\DatePicker::make('from') + ->label('From Date'), + Forms\Components\DatePicker::make('until') + ->label('Until Date'), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['from'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date), + ) + ->when( + $data['until'], + fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date), + ); + }) + ->indicateUsing(function (array $data): array { + $indicators = []; + if ($data['from'] ?? null) { + $indicators['from'] = 'From: ' . $data['from']; + } + if ($data['until'] ?? null) { + $indicators['until'] = 'Until: ' . $data['until']; + } + return $indicators; + }), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([]) + ->poll('60s'); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist + ->schema([ + Infolists\Components\Section::make('Audit Details') + ->schema([ + Infolists\Components\Grid::make(3) + ->schema([ + Infolists\Components\TextEntry::make('created_at') + ->label('Date/Time') + ->dateTime('F j, Y H:i:s'), + + Infolists\Components\TextEntry::make('user.name') + ->label('User') + ->default('System'), + + Infolists\Components\TextEntry::make('event') + ->label('Action') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'created' => 'success', + 'updated' => 'warning', + 'deleted' => 'danger', + 'restored' => 'info', + default => 'gray', + }) + ->formatStateUsing(fn (string $state): string => ucfirst($state)), + ]), + + Infolists\Components\Grid::make(3) + ->schema([ + Infolists\Components\TextEntry::make('auditable_type') + ->label('Model') + ->formatStateUsing(fn (string $state): string => class_basename($state)), + + Infolists\Components\TextEntry::make('auditable_id') + ->label('Record ID'), + + Infolists\Components\TextEntry::make('ip_address') + ->label('IP Address') + ->default('N/A'), + ]), + + Infolists\Components\TextEntry::make('url') + ->label('URL') + ->columnSpanFull(), + ]), + + Infolists\Components\Section::make('Changes') + ->schema([ + Infolists\Components\ViewEntry::make('changes') + ->view('filament.infolists.entries.audit-changes') + ->columnSpanFull(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListAudits::route('/'), + 'view' => Pages\ViewAudit::route('/{record}'), + ]; + } +} diff --git a/src/app/Filament/Resources/AuditResource/Pages/ListAudits.php b/src/app/Filament/Resources/AuditResource/Pages/ListAudits.php new file mode 100644 index 0000000..e0032ef --- /dev/null +++ b/src/app/Filament/Resources/AuditResource/Pages/ListAudits.php @@ -0,0 +1,16 @@ + 'array', + 'new_values' => 'array', + ]; + + /** + * Get the user that performed the action. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the auditable model. + */ + public function auditable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the event badge color. + */ + public function getEventColorAttribute(): string + { + return match ($this->event) { + 'created' => 'success', + 'updated' => 'warning', + 'deleted' => 'danger', + 'restored' => 'info', + default => 'gray', + }; + } + + /** + * Get a human-readable model name. + */ + public function getModelNameAttribute(): string + { + $class = class_basename($this->auditable_type); + return preg_replace('/(?old_values ?? []; + $newValues = $this->new_values ?? []; + + $allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues))); + + foreach ($allKeys as $key) { + $old = $oldValues[$key] ?? null; + $new = $newValues[$key] ?? null; + + if ($old !== $new) { + $changes[] = [ + 'field' => $key, + 'old' => $this->formatValue($old), + 'new' => $this->formatValue($new), + ]; + } + } + + return $changes; + } + + /** + * Format a value for display. + */ + protected function formatValue($value): string + { + if (is_null($value)) { + return '(empty)'; + } + + if (is_bool($value)) { + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + return json_encode($value); + } + + $stringValue = (string) $value; + + if (strlen($stringValue) > 100) { + return substr($stringValue, 0, 100) . '...'; + } + + return $stringValue; + } +} diff --git a/src/database/migrations/2026_03_09_024743_create_audits_table.php b/src/database/migrations/2026_03_09_024743_create_audits_table.php index 2aa8573..2ca4323 100644 --- a/src/database/migrations/2026_03_09_024743_create_audits_table.php +++ b/src/database/migrations/2026_03_09_024743_create_audits_table.php @@ -12,8 +12,20 @@ public function up(): void { Schema::create('audits', function (Blueprint $table) { - $table->id(); + $table->bigIncrements('id'); + $table->string('user_type')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('event'); + $table->morphs('auditable'); + $table->text('old_values')->nullable(); + $table->text('new_values')->nullable(); + $table->text('url')->nullable(); + $table->ipAddress('ip_address')->nullable(); + $table->string('user_agent', 1023)->nullable(); + $table->string('tags')->nullable(); $table->timestamps(); + + $table->index(['user_id', 'user_type']); }); } diff --git a/src/database/seeders/RolePermissionSeeder.php b/src/database/seeders/RolePermissionSeeder.php index 56b0658..d941c25 100644 --- a/src/database/seeders/RolePermissionSeeder.php +++ b/src/database/seeders/RolePermissionSeeder.php @@ -21,6 +21,7 @@ public function run(): void 'users.edit', 'users.delete', 'settings.manage', + 'audit.view', ]; foreach ($corePermissions as $permission) { diff --git a/src/resources/views/filament/infolists/entries/audit-changes.blade.php b/src/resources/views/filament/infolists/entries/audit-changes.blade.php new file mode 100644 index 0000000..889035f --- /dev/null +++ b/src/resources/views/filament/infolists/entries/audit-changes.blade.php @@ -0,0 +1,63 @@ +
No changes recorded.
+ @else +| Field | +Old Value | +New Value | +
|---|---|---|
| + {{ \Illuminate\Support\Str::headline($key) }} + | +
+ @if(is_null($oldValue))
+ (empty)
+ @elseif(is_array($oldValue))
+ {{ json_encode($oldValue) }}
+ @elseif(is_bool($oldValue))
+
+ {{ $oldValue ? 'Yes' : 'No' }}
+
+ @else
+ {{ \Illuminate\Support\Str::limit((string) $oldValue, 100) }}
+ @endif
+ |
+
+ @if(is_null($newValue))
+ (empty)
+ @elseif(is_array($newValue))
+ {{ json_encode($newValue) }}
+ @elseif(is_bool($newValue))
+
+ {{ $newValue ? 'Yes' : 'No' }}
+
+ @else
+ {{ \Illuminate\Support\Str::limit((string) $newValue, 100) }}
+ @endif
+ |
+