Add activity logging features by integrating Spatie's laravel-activitylog package. Update Permission and Role enums to include activity log permissions. Implement activity logging in Login and Logout controllers. Enhance sidebar navigation to include activity logs section. Update models to interact with activity logs through InteractsWithActivityLog concern.

This commit is contained in:
Yoga Pangestu 2026-06-12 11:10:36 +07:00
parent ec8b49be05
commit 1d1f0e7941
49 changed files with 969 additions and 5 deletions

View File

@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum ActivityEventLabel: string
{
case Created = 'created';
case Updated = 'updated';
case Deleted = 'deleted';
case Login = 'login';
case Logout = 'logout';
public function label(): string
{
return match ($this) {
self::Created => 'Dibuat',
self::Updated => 'Diperbarui',
self::Deleted => 'Dihapus',
self::Login => 'Masuk',
self::Logout => 'Keluar',
};
}
public static function labelFor(?string $event): string
{
if ($event === null || $event === '') {
return '-';
}
return self::tryFrom($event)?->label() ?? ucfirst($event);
}
/**
* @return list<array{value: string, label: string}>
*/
public static function selectOptions(): array
{
return collect(self::cases())
->map(fn (self $event) => [
'value' => $event->value,
'label' => $event->label(),
])
->all();
}
}

View File

@ -101,6 +101,8 @@ enum Permission: string
case SETTINGS_VIEW = 'settings.view';
case SETTINGS_UPDATE = 'settings.update';
case ACTIVITY_LOGS_VIEW = 'activity-logs.view';
public function label(): string
{
return match ($this) {
@ -196,6 +198,8 @@ public function label(): string
self::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Lihat Log Aktivitas',
};
}
@ -236,6 +240,7 @@ public function group(): string
self::PAYROLL_VIEW, self::PAYROLL_PAY, self::PAYROLL_ADJUST,
self::PAYROLL_CLOSE => 'Gaji',
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
};
}

View File

@ -109,6 +109,7 @@ public function permissions(): array
Permission::PAYROLL_PAY,
Permission::PAYROLL_ADJUST,
Permission::PAYROLL_CLOSE,
Permission::ACTIVITY_LOGS_VIEW,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -171,6 +172,7 @@ public function permissions(): array
Permission::PAYROLL_CLOSE,
Permission::SETTINGS_VIEW,
Permission::SETTINGS_UPDATE,
Permission::ACTIVITY_LOGS_VIEW,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Admin\System;
use App\Enums\ActivityEventLabel;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Services\System\ActivityLogService;
use App\Support\ActivityLog\ModelLabel;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ActivityLogController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly ActivityLogService $activityLogService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$event = $request->string('event')->toString();
$subjectType = $request->string('subject_type')->toString();
return Inertia::render('admin/system/activity-logs/Index', [
'activityLogs' => $this->activityLogService->paginateForIndex($tableQuery, $event, $subjectType),
'filters' => $this->dataTableFilters($tableQuery, [
'event' => $event,
'subject_type' => $subjectType,
]),
'eventOptions' => ActivityEventLabel::selectOptions(),
'subjectOptions' => ModelLabel::selectOptions(),
]);
}
}

View File

@ -19,9 +19,16 @@ public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$user = $request->user();
activity()
->causedBy($user)
->event('login')
->log('Berhasil masuk ke aplikasi');
$request->session()->regenerate();
$request->user()?->update(['last_login_at' => now()]);
$user?->update(['last_login_at' => now()]);
return redirect()
->intended(route('admin.dashboard'))

View File

@ -11,6 +11,13 @@ class LogoutController extends Controller
{
public function store(Request $request): RedirectResponse
{
if ($user = $request->user()) {
activity()
->causedBy($user)
->event('logout')
->log('Keluar dari aplikasi');
}
Auth::logout();
$request->session()->invalidate();

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use App\Support\Media\MediaPresenter;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -25,6 +26,7 @@
class Attendance extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
public static function mediaModuleName(): string
{

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -12,6 +13,8 @@
#[Appends(['balance_formatted'])]
class CashAccount extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -24,6 +25,7 @@
class CashTransaction extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
use SoftDeletes;
public static function mediaModuleName(): string

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -12,6 +13,7 @@
#[Sluggable(from: 'name', to: 'slug')]
class Category extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
public function products(): BelongsToMany

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models\Concerns;
use App\Support\ActivityLog\ModelLabel;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
trait InteractsWithActivityLog
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logUnguarded()
->logOnlyDirty()
->dontLogEmptyChanges()
->logExcept([
'password',
'remember_token',
'created_at',
'updated_at',
'deleted_at',
])
->setDescriptionForEvent(function (string $eventName): string {
$label = ModelLabel::for(static::class);
return match ($eventName) {
'created' => "{$label} dibuat",
'updated' => "{$label} diperbarui",
'deleted' => "{$label} dihapus",
default => "{$label} {$eventName}",
};
});
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -10,6 +11,7 @@
#[Guarded(['id'])]
class Customer extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
public function orders(): HasMany

View File

@ -4,6 +4,7 @@
use App\Enums\CuttingStatus;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -20,6 +21,7 @@
class Cutting extends Model
{
use HasRejection;
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -18,6 +19,8 @@
])]
class CuttingMaterial extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -9,6 +10,8 @@
#[Guarded(['id'])]
class CuttingResult extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\EmploymentStatus;
use App\Models\Concerns\InteractsWithActivityLog;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -18,6 +19,7 @@
#[Appends(['base_salary_formatted', 'join_date_formatted', 'resign_date_formatted', 'join_date_input', 'employment_status_label'])]
class Employee extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -5,6 +5,7 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -29,6 +30,7 @@ class EmployeeAdvance extends Model implements HasMedia
{
use HasModuleMedia;
use HasRejection;
use InteractsWithActivityLog;
public static function mediaModuleName(): string
{

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -16,6 +17,7 @@
class Expense extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
use SoftDeletes;
public static function mediaModuleName(): string

View File

@ -4,6 +4,7 @@
use App\Enums\LeaveRequestStatus;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -26,6 +27,7 @@
class LeaveRequest extends Model
{
use HasRejection;
use InteractsWithActivityLog;
protected function casts(): array
{

View File

@ -5,6 +5,7 @@
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PriceType;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -26,6 +27,7 @@
])]
class Order extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -17,6 +18,8 @@
])]
class OrderItem extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -5,6 +5,7 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -26,6 +27,8 @@
])]
class Payroll extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PayrollAdjustmentType;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -13,6 +14,8 @@
#[Appends(['amount_formatted', 'type_label', 'created_at_formatted', 'created_by_name'])]
class PayrollAdjustment extends Model
{
use InteractsWithActivityLog;
public $timestamps = false;
protected function casts(): array

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PayrollPeriodStatus;
use App\Models\Concerns\InteractsWithActivityLog;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -15,6 +16,8 @@
#[Appends(['period_label', 'status_label', 'closed_at_formatted'])]
class PayrollPeriod extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -13,6 +14,7 @@
#[Sluggable(from: 'name', to: 'slug')]
class Product extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PriceType;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -13,6 +14,8 @@
#[Appends(['price_formatted', 'price_input', 'type_label'])]
class ProductPrice extends Model
{
use InteractsWithActivityLog;
protected function casts(): array
{
return [

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,6 +15,7 @@
class ProductVariant extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
use SoftDeletes;
public static function mediaModuleName(): string

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -22,6 +23,7 @@
class Purchase extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
use SoftDeletes;
public static function mediaModuleName(): string

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -19,6 +20,7 @@
])]
class PurchaseItem extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\RawMaterialUnit;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
@ -16,6 +17,7 @@
#[Appends(['unit_label', 'unit_abbreviation'])]
class RawMaterial extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -17,6 +18,7 @@
class RawMaterialPrice extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
use SoftDeletes;
public static function mediaModuleName(): string

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -10,6 +11,8 @@
#[Guarded(['id'])]
class Rejection extends Model
{
use InteractsWithActivityLog;
public function rejectable(): MorphTo
{
return $this->morphTo();

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -10,6 +11,7 @@
#[Guarded(['id'])]
class Supplier extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
public function purchases(): HasMany

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
@ -11,6 +12,7 @@
class SystemConfiguration extends Model implements HasMedia
{
use HasModuleMedia;
use InteractsWithActivityLog;
public static function mediaModuleName(): string
{

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\Role;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@ -15,6 +16,7 @@
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Activitylog\Models\Concerns\CausesActivity;
use Spatie\Permission\Traits\HasRoles;
#[Guarded(['id'])]
@ -22,7 +24,7 @@
#[Appends(['role_label'])]
class User extends Authenticatable
{
use HasFactory, HasRoles, Notifiable, SoftDeletes;
use CausesActivity, HasFactory, HasRoles, InteractsWithActivityLog, Notifiable, SoftDeletes;
protected function casts(): array
{

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\Gender;
use App\Models\Concerns\InteractsWithActivityLog;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
@ -15,6 +16,7 @@
#[Appends(['birth_date_formatted', 'birth_date_input', 'gender_label'])]
class UserProfile extends Model
{
use InteractsWithActivityLog;
use SoftDeletes;
protected function casts(): array

View File

@ -0,0 +1,128 @@
<?php
namespace App\Services\System;
use App\Enums\ActivityEventLabel;
use App\Models\User;
use App\Support\ActivityLog\ModelLabel;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Spatie\Activitylog\Models\Activity;
class ActivityLogService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, string $event = '', string $subjectType = ''): LengthAwarePaginator
{
$query = Activity::query()
->with(['causer.profile'])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
->orWhereHasMorph(
'causer',
[User::class],
fn (Builder $query) => $query
->where('username', 'like', "%{$search}%")
->orWhereHas('profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")),
);
});
})
->when($event !== '', fn (Builder $query) => $query->where('event', $event))
->when($subjectType !== '', fn (Builder $query) => $query->where('subject_type', $subjectType));
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString()
->through(fn (Activity $activity) => $this->present($activity));
}
/**
* @return array{
* id: int,
* description: string,
* event: string|null,
* event_label: string,
* subject_type: string|null,
* subject_label: string,
* subject_id: int|null,
* causer_name: string,
* created_at: string|null,
* created_at_formatted: string|null,
* changes: list<array{field: string, old: mixed, new: mixed}>
* }
*/
private function present(Activity $activity): array
{
return [
'id' => $activity->id,
'description' => $activity->description,
'event' => $activity->event,
'event_label' => ActivityEventLabel::labelFor($activity->event),
'subject_type' => $activity->subject_type,
'subject_label' => ModelLabel::for($activity->subject_type),
'subject_id' => $activity->subject_id,
'causer_name' => $this->resolveCauserName($activity->causer),
'created_at' => $activity->created_at?->toIso8601String(),
'created_at_formatted' => $activity->created_at?->translatedFormat('l, d F Y H:i'),
'changes' => $this->formatChanges($activity->attribute_changes),
];
}
private function resolveCauserName(?Model $causer): string
{
if (! $causer instanceof User) {
return '-';
}
return $causer->profile?->full_name ?? $causer->username ?? '-';
}
/**
* @return list<array{field: string, old: mixed, new: mixed}>
*/
private function formatChanges(?Collection $changes): array
{
if ($changes === null || $changes->isEmpty()) {
return [];
}
$attributes = $changes->get('attributes', []);
$old = $changes->get('old', []);
if (! is_array($attributes)) {
return [];
}
$formatted = [];
foreach ($attributes as $field => $newValue) {
$formatted[] = [
'field' => (string) $field,
'old' => is_array($old) ? ($old[$field] ?? null) : null,
'new' => $newValue,
];
}
return $formatted;
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'description', 'event'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Support\ActivityLog;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\Category;
use App\Models\Customer;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingResult;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\Rejection;
use App\Models\Supplier;
use App\Models\SystemConfiguration;
use App\Models\User;
use App\Models\UserProfile;
class ModelLabel
{
/**
* @var array<class-string, string>
*/
private const LABELS = [
Attendance::class => 'Presensi',
CashAccount::class => 'Akun Kas',
CashTransaction::class => 'Transaksi Kas',
Category::class => 'Kategori',
Customer::class => 'Pelanggan',
Cutting::class => 'Cutting',
CuttingMaterial::class => 'Bahan Cutting',
CuttingResult::class => 'Hasil Cutting',
Employee::class => 'Pegawai',
EmployeeAdvance::class => 'Kasbon',
Expense::class => 'Pengeluaran',
LeaveRequest::class => 'Pengajuan Cuti',
Order::class => 'Pesanan',
OrderItem::class => 'Item Pesanan',
Payroll::class => 'Gaji',
PayrollAdjustment::class => 'Penyesuaian Gaji',
PayrollPeriod::class => 'Periode Gaji',
Product::class => 'Produk',
ProductPrice::class => 'Harga Produk',
ProductVariant::class => 'Varian Produk',
Purchase::class => 'Belanja',
PurchaseItem::class => 'Item Belanja',
RawMaterial::class => 'Bahan Baku',
RawMaterialPrice::class => 'Harga Bahan Baku',
Rejection::class => 'Penolakan',
Supplier::class => 'Supplier',
SystemConfiguration::class => 'Konfigurasi Sistem',
User::class => 'Pengguna',
UserProfile::class => 'Profil Pengguna',
];
public static function for(?string $modelClass): string
{
if ($modelClass === null) {
return '-';
}
return self::LABELS[$modelClass] ?? class_basename($modelClass);
}
/**
* @return list<array{value: string, label: string}>
*/
public static function selectOptions(): array
{
return collect(self::LABELS)
->map(fn (string $label, string $class) => [
'value' => $class,
'label' => $label,
])
->sortBy('label')
->values()
->all();
}
}

View File

@ -14,6 +14,7 @@
"laravel/framework": "^13.7",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.14",
"spatie/laravel-activitylog": "^5.0",
"spatie/laravel-medialibrary": "^11.23",
"spatie/laravel-permission": "^8.0",
"spatie/laravel-settings": "^3.9",

95
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6e75752243def7f52763c43a3fb6eb14",
"content-hash": "14f85c73d73d1a795e83a072d4d37995",
"packages": [
{
"name": "brick/math",
@ -3930,6 +3930,99 @@
},
"time": "2025-11-26T10:57:19+00:00"
},
{
"name": "spatie/laravel-activitylog",
"version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-activitylog.git",
"reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd",
"reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd",
"shasum": ""
},
"require": {
"illuminate/config": "^12.0 || ^13.0",
"illuminate/database": "^12.0 || ^13.0",
"illuminate/support": "^12.0 || ^13.0",
"php": "^8.4",
"spatie/laravel-package-tools": "^1.6.3"
},
"require-dev": {
"ext-json": "*",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.29",
"orchestra/testbench": "^10.0 || ^11.0",
"pestphp/pest": "^4.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\Activitylog\\ActivitylogServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Activitylog\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Sebastian De Deyne",
"email": "sebastian@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
},
{
"name": "Tom Witkowski",
"email": "dev.gummibeer@gmail.com",
"homepage": "https://gummibeer.de",
"role": "Developer"
}
],
"description": "A very simple activity logger to monitor the users of your website or application",
"homepage": "https://github.com/spatie/activitylog",
"keywords": [
"activity",
"laravel",
"log",
"spatie",
"user"
],
"support": {
"issues": "https://github.com/spatie/laravel-activitylog/issues",
"source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-03-25T10:04:54+00:00"
},
{
"name": "spatie/laravel-medialibrary",
"version": "11.23.0",

76
config/activitylog.php Normal file
View File

@ -0,0 +1,76 @@
<?php
use Spatie\Activitylog\Actions\CleanActivityLogAction;
use Spatie\Activitylog\Actions\LogActivityAction;
use Spatie\Activitylog\Models\Activity;
return [
/*
* If set to false, no activities will be saved to the database.
*/
'enabled' => env('ACTIVITYLOG_ENABLED', true),
/*
* When the clean command is executed, all recording activities older than
* the number of days specified here will be deleted.
*/
'clean_after_days' => 365,
/*
* If no log name is passed to the activity() helper
* we use this default log name.
*/
'default_log_name' => 'default',
/*
* You can specify an auth driver here that gets user models.
* If this is null we'll use the current Laravel auth driver.
*/
'default_auth_driver' => null,
/*
* If set to true, the subject relationship on activities
* will include soft deleted models.
*/
'include_soft_deleted_subjects' => true,
/*
* This model will be used to log activity.
* It should implement the Spatie\Activitylog\Contracts\Activity interface
* and extend Illuminate\Database\Eloquent\Model.
*/
'activity_model' => Activity::class,
/*
* These attributes will be excluded from logging for all models.
* Model-specific exclusions via logExcept() are merged with these.
*/
'default_except_attributes' => [
'password',
'remember_token',
],
/*
* When enabled, activities are buffered in memory and inserted in a
* single bulk query after the response has been sent to the client.
* This can significantly reduce the number of database queries when
* many activities are logged during a single request.
*
* Only enable this if your application logs a high volume of activities
* per request. Buffered activities will not have an ID until the
* buffer is flushed.
*/
'buffer' => [
'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false),
],
/*
* These action classes can be overridden to customize how activities
* are logged and cleaned. Your custom classes must extend the originals.
*/
'actions' => [
'log_activity' => LogActivityAction::class,
'clean_log' => CleanActivityLogAction::class,
],
];

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_log', function (Blueprint $table) {
$table->id();
$table->string('log_name')->nullable()->index();
$table->text('description');
$table->nullableMorphs('subject', 'subject');
$table->string('event')->nullable();
$table->nullableMorphs('causer', 'causer');
$table->json('attribute_changes')->nullable();
$table->json('properties')->nullable();
$table->timestamps();
});
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, FolderTree, History, Layers, LayoutDashboard, Package, Receipt, Scissors, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -45,7 +45,8 @@ const showMasterMenu = computed(() => (
|| can('suppliers.view') || can('customers.view')
));
const isSettingActive = computed(() => page.url.startsWith('/admin/system/setting'));
const showSystemMenu = computed(() => can('settings.view'));
const isActivityLogsActive = computed(() => page.url.startsWith('/admin/system/activity-logs'));
const showSystemMenu = computed(() => can('settings.view') || can('activity-logs.view'));
</script>
<template>
@ -239,6 +240,14 @@ const showSystemMenu = computed(() => can('settings.view'));
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('activity-logs.view')">
<SidebarMenuButton as-child tooltip="Log Aktivitas" :is-active="isActivityLogsActive">
<Link href="/admin/system/activity-logs">
<History />
<span>Log Aktivitas</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { ActivityLogListItem } from '@/types/activity-log';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
activityLog?: ActivityLogListItem | null;
}>();
const hasChanges = computed(() => (props.activityLog?.changes.length ?? 0) > 0);
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '-';
}
if (typeof value === 'boolean') {
return value ? 'Ya' : 'Tidak';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Detail Log Aktivitas</DialogTitle>
</DialogHeader>
<div v-if="activityLog" class="space-y-4 text-sm">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="text-muted-foreground">Waktu</p>
<p class="font-medium">{{ activityLog.created_at_formatted ?? '-' }}</p>
</div>
<div>
<p class="text-muted-foreground">Pengguna</p>
<p class="font-medium">{{ activityLog.causer_name }}</p>
</div>
<div>
<p class="text-muted-foreground">Aksi</p>
<p class="font-medium">{{ activityLog.event_label }}</p>
</div>
<div>
<p class="text-muted-foreground">Modul</p>
<p class="font-medium">{{ activityLog.subject_label }}</p>
</div>
</div>
<div>
<p class="text-muted-foreground">Deskripsi</p>
<p class="font-medium">{{ activityLog.description }}</p>
</div>
<div v-if="hasChanges" class="space-y-2">
<p class="font-medium">Perubahan Data</p>
<div class="overflow-hidden rounded-md border">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-3 py-2 text-left font-medium">Field</th>
<th class="px-3 py-2 text-left font-medium">Sebelum</th>
<th class="px-3 py-2 text-left font-medium">Sesudah</th>
</tr>
</thead>
<tbody>
<tr v-for="change in activityLog.changes" :key="change.field" class="border-t">
<td class="px-3 py-2 align-top font-medium">{{ change.field }}</td>
<td class="px-3 py-2 align-top text-muted-foreground">{{ formatValue(change.old) }}</td>
<td class="px-3 py-2 align-top">{{ formatValue(change.new) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else class="text-muted-foreground">Tidak ada perubahan data yang tercatat.</p>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,45 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/system/activity-logs/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { ActivityLogListItem } from '@/types/activity-log';
export function createColumns(onView: (activityLog: ActivityLogListItem) => void): ColumnDef<ActivityLogListItem>[] {
return [
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Waktu', column: 'created_at' }),
cell: ({ row }) => row.original.created_at_formatted ?? '-',
},
{
accessorKey: 'causer_name',
enableSorting: false,
header: 'Pengguna',
},
{
accessorKey: 'event_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Aksi', column: 'event' }),
},
{
accessorKey: 'subject_label',
enableSorting: false,
header: 'Modul',
},
{
accessorKey: 'description',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Deskripsi', column: 'description' }),
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
activityLog: row.original,
onView: () => onView(row.original),
}),
},
];
}

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { Eye } from '@lucide/vue';
import { Button } from '@/components/ui/button';
import type { ActivityLogListItem } from '@/types/activity-log';
defineProps<{
activityLog: ActivityLogListItem;
}>();
const emit = defineEmits<{
view: [];
}>();
</script>
<template>
<Button variant="ghost" size="icon-sm" @click="emit('view')">
<Eye class="size-4" />
<span class="sr-only">Lihat detail</span>
</Button>
</template>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import ActivityLogDetailModal from '@/components/admin/system/activity-logs/ActivityLogDetailModal.vue';
import { createColumns } from '@/components/admin/system/activity-logs/columns';
import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { ActivityLogListItem, PaginatedActivityLogs, SelectOption } from '@/types/activity-log';
import type { DataTableSort } from '@/types/data-table';
const props = defineProps<{
activityLogs: PaginatedActivityLogs;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
event?: string;
subject_type?: string;
};
eventOptions: SelectOption[];
subjectOptions: SelectOption[];
}>();
const search = ref(props.filters.search ?? '');
const detailModalOpen = ref(false);
const selectedActivityLog = ref<ActivityLogListItem | null>(null);
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/system/activity-logs',
initial: { ...props.filters },
filterKeys: ['event', 'subject_type'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(openDetailModal));
const filterDefs = computed(() => [
{
key: 'event',
label: 'Aksi',
type: 'select' as const,
options: props.eventOptions,
},
{
key: 'subject_type',
label: 'Modul',
type: 'select' as const,
options: props.subjectOptions,
},
]);
const filterValues = computed(() => ({
event: query.value.event ?? '',
subject_type: query.value.subject_type ?? '',
}));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.activityLogs.current_page,
perPage: props.activityLogs.per_page,
lastPage: props.activityLogs.last_page,
total: props.activityLogs.total,
}));
function openDetailModal(activityLog: ActivityLogListItem) {
selectedActivityLog.value = activityLog;
detailModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Log Aktivitas" />
<AdminLayout>
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Log Aktivitas
</h2>
<p class="text-sm text-muted-foreground">
Riwayat semua aksi pengguna untuk keperluan audit.
</p>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="activityLogs.data"
:pagination="pagination" :pagination-links="activityLogs.links" :sort="currentSort"
:filter-defs="filterDefs" :filter-values="filterValues" search-placeholder="Cari log aktivitas..."
@sort-change="setSort" @filter-change="setFilter" @filters-reset="resetFilters" />
</CardContent>
</Card>
<ActivityLogDetailModal v-model:open="detailModalOpen" :activity-log="selectedActivityLog" />
</AdminLayout>
</template>

View File

@ -0,0 +1,45 @@
export type ActivityLogChange = {
field: string;
old: unknown;
new: unknown;
};
export type ActivityLogListItem = {
id: number;
description: string;
event: string | null;
event_label: string;
subject_type: string | null;
subject_label: string;
subject_id: number | null;
causer_name: string;
created_at: string | null;
created_at_formatted: string | null;
changes: ActivityLogChange[];
};
export type ActivityLogFilters = {
search: string;
sort?: string;
direction?: 'asc' | 'desc' | null;
event?: string;
subject_type?: string;
};
export type PaginatedActivityLogs = {
data: ActivityLogListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type SelectOption = {
value: string;
label: string;
};

View File

@ -21,6 +21,7 @@
use App\Http\Controllers\Admin\Master\ProductController;
use App\Http\Controllers\Admin\Master\RawMaterialController;
use App\Http\Controllers\Admin\Master\SupplierController;
use App\Http\Controllers\Admin\System\ActivityLogController;
use App\Http\Controllers\Admin\System\SettingController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\LogoutController;
@ -244,6 +245,12 @@
});
Route::prefix('system')->name('system.')->group(function () {
Route::prefix('activity-logs')->name('activity-logs.')
->middleware('permission:'.Permission::ACTIVITY_LOGS_VIEW->value)
->group(function () {
Route::get('/', [ActivityLogController::class, 'index'])->name('index');
});
Route::prefix('setting')->name('setting.')
->middleware('permission:'.Permission::SETTINGS_VIEW->value)
->group(function () {