321 lines
13 KiB
PHP
321 lines
13 KiB
PHP
<?php
|
|
|
|
use App\Models\AdminLog;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
new class extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
public string $actionFilter = '';
|
|
public string $targetFilter = '';
|
|
public string $dateFrom = '';
|
|
public string $dateTo = '';
|
|
public string $search = '';
|
|
public ?int $selectedLogId = null;
|
|
|
|
public function updatedActionFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedTargetFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedDateFrom(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedDateTo(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function resetFilters(): void
|
|
{
|
|
$this->reset(['actionFilter', 'targetFilter', 'dateFrom', 'dateTo', 'search']);
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function showDetails(int $logId): void
|
|
{
|
|
$this->selectedLogId = $logId;
|
|
$this->dispatch('open-modal', name: 'log-details');
|
|
}
|
|
|
|
public function closeModal(): void
|
|
{
|
|
$this->selectedLogId = null;
|
|
$this->dispatch('close-modal', name: 'log-details');
|
|
}
|
|
|
|
public function exportCsv(): StreamedResponse
|
|
{
|
|
$logs = $this->getFilteredQuery()->get();
|
|
|
|
return response()->streamDownload(function () use ($logs) {
|
|
$handle = fopen('php://output', 'w');
|
|
|
|
// Header row (bilingual based on locale)
|
|
fputcsv($handle, [
|
|
__('audit.timestamp'),
|
|
__('audit.admin'),
|
|
__('audit.action'),
|
|
__('audit.target_type'),
|
|
__('audit.target_id'),
|
|
__('audit.ip_address'),
|
|
]);
|
|
|
|
foreach ($logs as $log) {
|
|
fputcsv($handle, [
|
|
$log->created_at->format('Y-m-d H:i:s'),
|
|
$log->admin?->name ?? __('audit.system'),
|
|
$log->action,
|
|
$log->target_type,
|
|
$log->target_id,
|
|
$log->ip_address,
|
|
]);
|
|
}
|
|
|
|
fclose($handle);
|
|
}, 'audit-log-' . now()->format('Y-m-d') . '.csv');
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
return [
|
|
'logs' => $this->getFilteredQuery()->paginate(25),
|
|
'actionTypes' => AdminLog::distinct()->pluck('action'),
|
|
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
|
|
'selectedLog' => $this->selectedLogId
|
|
? AdminLog::with('admin')->find($this->selectedLogId)
|
|
: null,
|
|
];
|
|
}
|
|
|
|
private function getFilteredQuery()
|
|
{
|
|
return AdminLog::query()
|
|
->with('admin')
|
|
->when($this->actionFilter, fn ($q) => $q->where('action', $this->actionFilter))
|
|
->when($this->targetFilter, fn ($q) => $q->where('target_type', $this->targetFilter))
|
|
->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
|
|
->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
|
|
->when($this->search, fn ($q) => $q->where('target_id', $this->search))
|
|
->latest('created_at');
|
|
}
|
|
|
|
private function getActionColor(string $action): string
|
|
{
|
|
return match ($action) {
|
|
'create', 'approve' => 'green',
|
|
'update', 'status_change' => 'blue',
|
|
'delete', 'reject' => 'red',
|
|
'archive' => 'amber',
|
|
default => 'zinc',
|
|
};
|
|
}
|
|
}; ?>
|
|
|
|
<div class="max-w-7xl mx-auto">
|
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
|
<div>
|
|
<flux:heading size="xl">{{ __('audit.audit_logs') }}</flux:heading>
|
|
<p class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{{ __('audit.audit_logs_description') }}</p>
|
|
</div>
|
|
<flux:button wire:click="exportCsv" icon="arrow-down-tray">
|
|
{{ __('audit.export_csv') }}
|
|
</flux:button>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="bg-white dark:bg-zinc-800 rounded-lg p-4 border border-zinc-200 dark:border-zinc-700 mb-6">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
|
<flux:field>
|
|
<flux:select wire:model.live="actionFilter">
|
|
<option value="">{{ __('audit.all_actions') }}</option>
|
|
@foreach($actionTypes as $type)
|
|
<option value="{{ $type }}">{{ __("audit.action_{$type}") }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:select wire:model.live="targetFilter">
|
|
<option value="">{{ __('audit.all_targets') }}</option>
|
|
@foreach($targetTypes as $type)
|
|
<option value="{{ $type }}">{{ __("audit.target_{$type}") }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('admin.date_from') }}</flux:label>
|
|
<flux:input type="date" wire:model.live="dateFrom" />
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('admin.date_to') }}</flux:label>
|
|
<flux:input type="date" wire:model.live="dateTo" />
|
|
</flux:field>
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-4 items-end">
|
|
<flux:field class="flex-1">
|
|
<flux:input
|
|
wire:model.live.debounce.300ms="search"
|
|
placeholder="{{ __('audit.search_target_id') }}"
|
|
icon="magnifying-glass"
|
|
/>
|
|
</flux:field>
|
|
|
|
@if($actionFilter || $targetFilter || $dateFrom || $dateTo || $search)
|
|
<flux:button wire:click="resetFilters" variant="ghost">
|
|
{{ __('audit.reset') }}
|
|
</flux:button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sort Headers -->
|
|
<div class="hidden lg:flex bg-zinc-100 dark:bg-zinc-700 rounded-t-lg px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 gap-4 mb-0">
|
|
<span class="w-40">{{ __('audit.timestamp') }}</span>
|
|
<span class="w-32">{{ __('audit.admin') }}</span>
|
|
<span class="w-28">{{ __('audit.action') }}</span>
|
|
<span class="flex-1">{{ __('audit.target') }}</span>
|
|
<span class="w-36">{{ __('audit.ip_address') }}</span>
|
|
<span class="w-24">{{ __('audit.details') }}</span>
|
|
</div>
|
|
|
|
<!-- Logs List -->
|
|
<div class="space-y-0">
|
|
@forelse($logs as $log)
|
|
<div wire:key="log-{{ $log->id }}" class="bg-white dark:bg-zinc-800 p-4 border border-zinc-200 dark:border-zinc-700 {{ $loop->first ? 'rounded-t-lg lg:rounded-t-none' : '' }} {{ $loop->last ? 'rounded-b-lg' : '' }} {{ !$loop->first ? 'border-t-0' : '' }}">
|
|
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
|
|
<!-- Timestamp -->
|
|
<div class="lg:w-40">
|
|
<div class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
|
{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin -->
|
|
<div class="lg:w-32">
|
|
<div class="text-sm text-zinc-900 dark:text-zinc-100">
|
|
{{ $log->admin?->name ?? __('audit.system') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Badge -->
|
|
<div class="lg:w-28">
|
|
@php
|
|
$actionColor = match($log->action) {
|
|
'create', 'approve' => 'green',
|
|
'update', 'status_change' => 'blue',
|
|
'delete', 'reject' => 'red',
|
|
'archive' => 'amber',
|
|
default => 'zinc',
|
|
};
|
|
@endphp
|
|
<flux:badge :color="$actionColor" size="sm">
|
|
{{ __("audit.action_{$log->action}") }}
|
|
</flux:badge>
|
|
</div>
|
|
|
|
<!-- Target -->
|
|
<div class="flex-1">
|
|
<div class="text-sm text-zinc-900 dark:text-zinc-100">
|
|
{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IP Address -->
|
|
<div class="lg:w-36">
|
|
<div class="text-sm text-zinc-500 dark:text-zinc-400 font-mono">
|
|
{{ $log->ip_address }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="lg:w-24">
|
|
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
|
|
{{ __('audit.details') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@empty
|
|
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400 bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
|
<flux:icon name="clipboard-document-list" class="w-12 h-12 mx-auto mb-4" />
|
|
<p>{{ $actionFilter || $targetFilter || $dateFrom || $dateTo || $search ? __('audit.no_results') : __('audit.no_logs_found') }}</p>
|
|
</div>
|
|
@endforelse
|
|
</div>
|
|
|
|
<div class="mt-6">
|
|
{{ $logs->links() }}
|
|
</div>
|
|
|
|
<!-- Detail Modal -->
|
|
<flux:modal name="log-details" class="max-w-2xl">
|
|
@if($selectedLog)
|
|
<div class="space-y-6">
|
|
<flux:heading size="lg">{{ __('audit.log_details') }}</flux:heading>
|
|
|
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
<div>
|
|
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ __('audit.timestamp') }}:</span>
|
|
<span class="ms-2 text-zinc-900 dark:text-zinc-100">{{ $selectedLog->created_at->format('Y-m-d H:i:s') }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ __('audit.admin') }}:</span>
|
|
<span class="ms-2 text-zinc-900 dark:text-zinc-100">{{ $selectedLog->admin?->name ?? __('audit.system') }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ __('audit.action') }}:</span>
|
|
<span class="ms-2 text-zinc-900 dark:text-zinc-100">{{ __("audit.action_{$selectedLog->action}") }}</span>
|
|
</div>
|
|
<div>
|
|
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ __('audit.target') }}:</span>
|
|
<span class="ms-2 text-zinc-900 dark:text-zinc-100">{{ __("audit.target_{$selectedLog->target_type}") }} #{{ $selectedLog->target_id }}</span>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<span class="font-medium text-zinc-500 dark:text-zinc-400">{{ __('audit.ip_address') }}:</span>
|
|
<span class="ms-2 text-zinc-900 dark:text-zinc-100 font-mono">{{ $selectedLog->ip_address }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
@if($selectedLog->old_values)
|
|
<div>
|
|
<strong class="text-zinc-700 dark:text-zinc-300">{{ __('audit.old_values') }}:</strong>
|
|
<pre class="bg-zinc-100 dark:bg-zinc-900 p-3 rounded-lg text-sm overflow-auto mt-2 text-zinc-800 dark:text-zinc-200" dir="ltr">{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
|
</div>
|
|
@endif
|
|
|
|
@if($selectedLog->new_values)
|
|
<div>
|
|
<strong class="text-zinc-700 dark:text-zinc-300">{{ __('audit.new_values') }}:</strong>
|
|
<pre class="bg-zinc-100 dark:bg-zinc-900 p-3 rounded-lg text-sm overflow-auto mt-2 text-zinc-800 dark:text-zinc-200" dir="ltr">{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="flex justify-end">
|
|
<flux:button wire:click="closeModal">
|
|
{{ __('common.close') }}
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</flux:modal>
|
|
</div>
|