libra/resources/views/livewire/admin/audit-logs.blade.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>