Skip to content

Commit 43386ad

Browse files
committed
feat: Phase 23 — Audit Log viewer with event and model filters
Adds a settings-scoped audit log viewer at /settings/audit-log accessible to admin and super-admin roles, with event and model filtering, paginated table display, and value diff expansion. Includes 6 feature tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 413393f commit 43386ad

5 files changed

Lines changed: 258 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Modules\Core\Models\AuditLog;
6+
use Illuminate\Http\Request;
7+
use Inertia\Inertia;
8+
9+
class AuditLogController extends Controller
10+
{
11+
public function index(Request $request)
12+
{
13+
// Only admin/super-admin can view audit logs
14+
if (! $request->user()->hasAnyRole(['super-admin', 'admin'])) {
15+
abort(403);
16+
}
17+
18+
$tenantId = $request->user()->tenant_id;
19+
20+
$query = AuditLog::where('tenant_id', $tenantId)
21+
->with('user')
22+
->orderByDesc('created_at');
23+
24+
// Optional filters
25+
if ($event = $request->query('event')) {
26+
$query->where('event', $event);
27+
}
28+
if ($model = $request->query('model')) {
29+
$query->where('auditable_type', 'like', "%{$model}%");
30+
}
31+
32+
$logs = $query->paginate(50)->withQueryString()->through(fn ($log) => [
33+
'id' => $log->id,
34+
'event' => $log->event,
35+
'model_name' => class_basename($log->auditable_type),
36+
'auditable_id' => $log->auditable_id,
37+
'user_name' => $log->user?->name ?? 'System',
38+
'old_values' => $log->old_values,
39+
'new_values' => $log->new_values,
40+
'ip_address' => $log->ip_address,
41+
'created_at' => $log->created_at?->toISOString(),
42+
]);
43+
44+
return Inertia::render('Settings/AuditLog', [
45+
'logs' => $logs,
46+
'filter_event' => $request->query('event', ''),
47+
'filter_model' => $request->query('model', ''),
48+
]);
49+
}
50+
}

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const navItems: NavItem[] = [
133133
children: [
134134
{ label: 'Users', href: '/settings/users', icon: <span /> },
135135
{ label: 'Company', href: '/settings/company', icon: <span /> },
136+
{ label: 'Audit Log', href: '/settings/audit-log', icon: <span /> },
136137
],
137138
},
138139
];
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { Head, Link, useForm } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import type { PageProps } from '@/types';
4+
import type { Paginator } from '@/types/inventory';
5+
6+
interface AuditEntry {
7+
id: number;
8+
event: string;
9+
model_name: string;
10+
auditable_id: number;
11+
user_name: string;
12+
old_values: Record<string, unknown> | null;
13+
new_values: Record<string, unknown> | null;
14+
ip_address: string | null;
15+
created_at: string;
16+
}
17+
18+
interface Props extends PageProps {
19+
logs: Paginator<AuditEntry>;
20+
filter_event: string;
21+
filter_model: string;
22+
}
23+
24+
const EVENT_COLORS: Record<string, string> = {
25+
created: 'bg-green-100 text-green-700',
26+
updated: 'bg-blue-100 text-blue-700',
27+
deleted: 'bg-red-100 text-red-700',
28+
};
29+
30+
export default function AuditLog({ logs, filter_event, filter_model }: Props) {
31+
const { data, setData, get } = useForm({
32+
event: filter_event,
33+
model: filter_model,
34+
});
35+
36+
function applyFilter(e: React.FormEvent) {
37+
e.preventDefault();
38+
get('/settings/audit-log');
39+
}
40+
41+
return (
42+
<AppLayout>
43+
<Head title="Audit Log" />
44+
<div className="space-y-6">
45+
<h1 className="text-2xl font-semibold text-slate-900">Audit Log</h1>
46+
47+
{/* Filters */}
48+
<form onSubmit={applyFilter} className="flex items-end gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
49+
<div>
50+
<label className="block text-xs font-medium text-slate-500 mb-1">Event</label>
51+
<select value={data.event} onChange={(e) => setData('event', e.target.value)}
52+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
53+
<option value="">All events</option>
54+
<option value="created">Created</option>
55+
<option value="updated">Updated</option>
56+
<option value="deleted">Deleted</option>
57+
</select>
58+
</div>
59+
<div>
60+
<label className="block text-xs font-medium text-slate-500 mb-1">Model</label>
61+
<input type="text" placeholder="e.g. Invoice" value={data.model} onChange={(e) => setData('model', e.target.value)}
62+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none w-40" />
63+
</div>
64+
<button type="submit"
65+
className="rounded-md bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
66+
Filter
67+
</button>
68+
{(filter_event || filter_model) && (
69+
<Link href="/settings/audit-log" className="text-sm text-slate-500 hover:text-slate-700">Clear</Link>
70+
)}
71+
</form>
72+
73+
{/* Log table */}
74+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden">
75+
<table className="w-full text-sm">
76+
<thead className="bg-slate-50 text-xs text-slate-500 uppercase border-b border-slate-200">
77+
<tr>
78+
<th className="px-4 py-2 text-left font-medium">Time</th>
79+
<th className="px-4 py-2 text-left font-medium">User</th>
80+
<th className="px-4 py-2 text-left font-medium">Event</th>
81+
<th className="px-4 py-2 text-left font-medium">Model</th>
82+
<th className="px-4 py-2 text-left font-medium">ID</th>
83+
<th className="px-4 py-2 text-left font-medium">Changes</th>
84+
<th className="px-4 py-2 text-left font-medium">IP</th>
85+
</tr>
86+
</thead>
87+
<tbody className="divide-y divide-slate-50">
88+
{logs.data.length === 0 ? (
89+
<tr><td colSpan={7} className="px-4 py-8 text-center text-slate-400">No audit entries found.</td></tr>
90+
) : logs.data.map((entry) => (
91+
<tr key={entry.id} className="hover:bg-slate-50 align-top">
92+
<td className="px-4 py-3 text-slate-500 whitespace-nowrap text-xs">
93+
{new Date(entry.created_at).toLocaleString()}
94+
</td>
95+
<td className="px-4 py-3 font-medium text-slate-800">{entry.user_name}</td>
96+
<td className="px-4 py-3">
97+
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_COLORS[entry.event] ?? 'bg-slate-100 text-slate-600'}`}>
98+
{entry.event}
99+
</span>
100+
</td>
101+
<td className="px-4 py-3 text-slate-700">{entry.model_name}</td>
102+
<td className="px-4 py-3 text-slate-500">#{entry.auditable_id}</td>
103+
<td className="px-4 py-3 max-w-xs">
104+
{entry.new_values && Object.keys(entry.new_values).length > 0 && (
105+
<details className="text-xs">
106+
<summary className="cursor-pointer text-indigo-600 hover:underline">View changes</summary>
107+
<pre className="mt-1 bg-slate-50 rounded p-2 text-slate-600 overflow-x-auto text-xs max-h-32">
108+
{JSON.stringify(entry.new_values, null, 2)}
109+
</pre>
110+
</details>
111+
)}
112+
</td>
113+
<td className="px-4 py-3 text-xs text-slate-400">{entry.ip_address ?? '—'}</td>
114+
</tr>
115+
))}
116+
</tbody>
117+
</table>
118+
</div>
119+
120+
{/* Pagination */}
121+
{logs.last_page > 1 && (
122+
<div className="flex justify-center gap-2">
123+
{logs.prev_page_url && (
124+
<Link href={logs.prev_page_url} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50">
125+
&larr; Previous
126+
</Link>
127+
)}
128+
<span className="px-3 py-1.5 text-sm text-slate-500">Page {logs.current_page} of {logs.last_page}</span>
129+
{logs.next_page_url && (
130+
<Link href={logs.next_page_url} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50">
131+
Next &rarr;
132+
</Link>
133+
)}
134+
</div>
135+
)}
136+
</div>
137+
</AppLayout>
138+
);
139+
}

erp/routes/web.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Controllers\AuditLogController;
34
use App\Http\Controllers\CompanySettingsController;
45
use App\Http\Controllers\DashboardController;
56
use App\Http\Controllers\ProfileController;
@@ -37,6 +38,8 @@
3738
Route::get('company', [CompanySettingsController::class, 'show'])->name('settings.company.show');
3839
Route::patch('company', [CompanySettingsController::class, 'update'])->name('settings.company.update');
3940
Route::post('company/logo', [CompanySettingsController::class, 'uploadLogo'])->name('settings.company.logo');
41+
42+
Route::get('audit-log', [AuditLogController::class, 'index'])->name('settings.audit-log');
4043
});
4144

4245
require __DIR__ . '/auth.php';
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\AuditLog;
5+
use App\Modules\Core\Models\Tenant;
6+
use App\Modules\Finance\Models\Contact;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'Audit Co', 'slug' => 'audit-co']);
12+
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->admin->assignRole('admin');
14+
$this->manager = User::factory()->create(['tenant_id' => $this->tenant->id]);
15+
$this->manager->assignRole('manager');
16+
$this->staff = User::factory()->create(['tenant_id' => $this->tenant->id]);
17+
$this->staff->assignRole('staff');
18+
});
19+
20+
test('admin can view audit log', function () {
21+
$this->actingAs($this->admin)
22+
->get('/settings/audit-log')
23+
->assertStatus(200)
24+
->assertInertia(fn ($p) => $p
25+
->component('Settings/AuditLog')
26+
->has('logs')
27+
);
28+
});
29+
30+
test('manager cannot access audit log', function () {
31+
$this->actingAs($this->manager)
32+
->get('/settings/audit-log')
33+
->assertStatus(403);
34+
});
35+
36+
test('staff cannot access audit log', function () {
37+
$this->actingAs($this->staff)
38+
->get('/settings/audit-log')
39+
->assertStatus(403);
40+
});
41+
42+
test('guest is redirected', function () {
43+
$this->get('/settings/audit-log')->assertRedirect();
44+
});
45+
46+
test('audit log records invoice creation', function () {
47+
$contact = Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'C', 'type' => 'customer']);
48+
$this->actingAs($this->admin)
49+
->post('/finance/invoices', [
50+
'contact_id' => $contact->id,
51+
'issue_date' => '2026-06-01',
52+
'items' => [['description' => 'Svc', 'quantity' => 1, 'unit_price' => 100, 'tax_rate' => 0]],
53+
]);
54+
55+
$this->actingAs($this->admin)
56+
->get('/settings/audit-log')
57+
->assertInertia(fn ($p) => $p->has('logs.data'));
58+
});
59+
60+
test('audit log can be filtered by event', function () {
61+
$this->actingAs($this->admin)
62+
->get('/settings/audit-log?event=created')
63+
->assertStatus(200)
64+
->assertInertia(fn ($p) => $p->where('filter_event', 'created'));
65+
});

0 commit comments

Comments
 (0)