refactor: enhance permission and role management by introducing group categorization for permissions and streamlining role permission assignments for improved maintainability

This commit is contained in:
Yoga Pangestu 2026-06-22 17:57:18 +07:00
parent 9a35c1cae2
commit da3e3a6895
25 changed files with 1713 additions and 346 deletions

View File

@ -222,4 +222,49 @@ public function label(): string
self::ROLES_DELETE => 'Hapus Role',
};
}
public function group(): string
{
return match ($this) {
self::DASHBOARD_VIEW => 'Umum',
self::EMPLOYEES_VIEW, self::EMPLOYEES_CREATE, self::EMPLOYEES_UPDATE,
self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai',
self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE,
self::ATTENDANCES_MANAGE => 'Presensi',
self::LEAVE_REQUESTS_VIEW, self::LEAVE_REQUESTS_CREATE, self::LEAVE_REQUESTS_UPDATE,
self::LEAVE_REQUESTS_DELETE, self::LEAVE_REQUESTS_VERIFY => 'Cuti',
self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE,
self::CATEGORIES_DELETE => 'Kategori',
self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE,
self::SUPPLIERS_DELETE => 'Supplier',
self::CUSTOMERS_VIEW, self::CUSTOMERS_CREATE, self::CUSTOMERS_UPDATE,
self::CUSTOMERS_DELETE => 'Pelanggan',
self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE,
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
self::PURCHASES_DELETE => 'Belanja',
self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE,
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
self::ORDERS_CANCEL => 'Pesanan',
self::CUTTINGS_VIEW, self::CUTTINGS_CREATE, self::CUTTINGS_UPDATE,
self::CUTTINGS_DELETE, self::CUTTINGS_COMPLETE, self::CUTTINGS_VERIFY,
self::CUTTINGS_REJECT => 'Cutting',
self::OWNER_VERIFICATIONS_VIEW, self::OWNER_VERIFICATIONS_VERIFY,
self::OWNER_VERIFICATIONS_REJECT => 'Verifikasi Owner',
self::STOCKS_VIEW => 'Stok',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_WITHDRAW, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
self::EXPENSES_DELETE => 'Pengeluaran',
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji',
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
self::ROLES_DELETE => 'Role & Permission',
};
}
}

View File

@ -30,4 +30,259 @@ public function label(): string
self::NON_OPERATOR => 'Non Operator',
};
}
public function permissions(): array
{
return match ($this) {
self::DEVELOPER, self::OWNER => array_values(array_filter(
Permission::cases(),
fn (Permission $permission) => ! in_array($permission, [
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
], true)
)),
self::DIREKTUR => [
Permission::DASHBOARD_VIEW,
Permission::EMPLOYEES_VIEW,
Permission::STOCKS_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::ATTENDANCES_MANAGE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::SUPPLIERS_VIEW,
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::RAW_MATERIALS_VIEW,
Permission::PURCHASES_VIEW,
Permission::ORDERS_VIEW,
Permission::CUTTINGS_VIEW,
Permission::CASH_VIEW,
Permission::EXPENSES_VIEW,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
Permission::STOCKS_VIEW,
Permission::CUTTINGS_VERIFY,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CATEGORIES_VIEW,
Permission::CATEGORIES_CREATE,
Permission::CATEGORIES_UPDATE,
Permission::CATEGORIES_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_WITHDRAW,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
Permission::EXPENSES_VIEW,
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
Permission::PAYROLL_ADJUST,
],
self::CASHIER => [
Permission::DASHBOARD_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::ATTENDANCES_DELETE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::CUSTOMERS_CREATE,
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
Permission::PAYROLL_ADJUST,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::SUPPLIERS_VIEW,
Permission::SUPPLIERS_CREATE,
Permission::SUPPLIERS_UPDATE,
Permission::SUPPLIERS_DELETE,
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::PRODUCTS_VIEW,
Permission::CUTTINGS_VIEW,
Permission::CUTTINGS_CREATE,
Permission::CUTTINGS_UPDATE,
Permission::CUTTINGS_DELETE,
Permission::CUTTINGS_COMPLETE,
Permission::CUTTINGS_VERIFY,
Permission::PURCHASES_VIEW,
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
self::MARKETING => [
Permission::DASHBOARD_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,
Permission::DASHBOARD_VIEW,
Permission::ATTENDANCES_VIEW,
Permission::ATTENDANCES_CREATE,
Permission::LEAVE_REQUESTS_VIEW,
Permission::LEAVE_REQUESTS_CREATE,
Permission::LEAVE_REQUESTS_UPDATE,
Permission::LEAVE_REQUESTS_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
Permission::PAYROLL_VIEW,
],
};
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin\System;
use App\Enums\Permission as PermissionEnum;
use App\Enums\Role as EnumsRole;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
@ -35,13 +34,11 @@ public function index(Request $request): Response
public function create(): Response
{
$permissionGroups = config('permissions.groups');
$permissions = collect(PermissionEnum::cases())
->map(fn (PermissionEnum $permission) => [
'value' => $permission->value,
'label' => $permission->label(),
'group' => $permissionGroups[$permission->value] ?? '-',
'group' => $permission->group(),
])
->values()
->all();
@ -64,26 +61,20 @@ public function edit(Role $role): Response
{
$role->load('permissions');
$permissionGroups = config('permissions.groups');
$permissions = collect(PermissionEnum::cases())
->map(fn (PermissionEnum $permission) => [
'value' => $permission->value,
'label' => $permission->label(),
'group' => $permissionGroups[$permission->value] ?? '-',
'group' => $permission->group(),
])
->values()
->all();
// Check if role is developer or owner to protect it
$isProtected = in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true);
return Inertia::render('admin/system/roles/Edit', [
'role' => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->permissions->pluck('name'),
'is_protected' => $isProtected,
],
'permissions' => $permissions,
]);

View File

@ -7,15 +7,11 @@
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()
@ -42,53 +38,21 @@ public function paginateForIndex(array $tableQuery, string $event = '', string $
return $query
->paginate(10)
->withQueryString()
->through(fn (Activity $activity) => $this->present($activity));
->through(fn (Activity $activity) => [
'id' => $activity->id,
'description' => $activity->description,
'event' => $activity->event,
'event_label' => ActivityEventLabel::from($activity->event)->label(),
'subject_type' => $activity->subject_type,
'subject_label' => ModelLabel::for($activity->subject_type),
'subject_id' => $activity->subject_id,
'causer_name' => $activity->causer?->profile?->full_name ?? $activity->causer?->username ?? '-',
'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),
]);
}
/**
* @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()) {

View File

@ -11,9 +11,6 @@
class RoleService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = Role::query()
@ -32,9 +29,6 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
->withQueryString();
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated): void
{
DB::transaction(function () use ($validated): void {
@ -49,13 +43,9 @@ public function create(array $validated): void
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Role $role, array $validated): void
{
DB::transaction(function () use ($role, $validated): void {
// Protect developer and owner names from changing
$updateData = [];
if (! in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true)) {
$updateData['name'] = Str::slug($validated['name']);
@ -68,10 +58,6 @@ public function update(Role $role, array $validated): void
public function delete(Role $role): void
{
if (in_array($role->name, [EnumsRole::DEVELOPER->value, EnumsRole::OWNER->value], true)) {
throw new \InvalidArgumentException('Role developer atau owner tidak dapat dihapus.');
}
$role->delete();
}

View File

@ -23,15 +23,11 @@ public function run(): void
$registrar->forgetCachedPermissions();
$rolePermissions = config('roles.permissions');
foreach (RoleEnum::cases() as $role) {
$roleModel = Role::findOrCreate($role->value, 'web');
$rolePermissionsList = $rolePermissions[$role->value] ?? [];
$roleModel->syncPermissions(
collect($rolePermissionsList)
collect($role->permissions())
->map(fn (PermissionEnum $permission) => $permissions[$permission->value])
->all()
);

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import ActivityLogDetailModal from './table/ActivityLogDetailModal.vue';
import { createColumns } from './table/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';
import ActivityLogDetailModal from './table/ActivityLogDetailModal.vue';
import { createColumns } from './table/columns';
const props = defineProps<{
activityLogs: PaginatedActivityLogs;
@ -101,17 +101,14 @@ watch(
<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" />
<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>

View File

@ -79,15 +79,14 @@ function formatValue(value: unknown): string {
<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 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>

View File

@ -1,8 +1,8 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from './data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import type { ActivityLogListItem } from '@/types/activity-log';
import DataTableActions from './data-table-actions.vue';
export function createColumns(onView: (activityLog: ActivityLogListItem) => void): ColumnDef<ActivityLogListItem>[] {
return [

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RoleForm from './form/RoleForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import RoleForm from './form/RoleForm.vue';
interface PermissionOption {
value: string;
@ -17,6 +17,7 @@ defineProps<{
</script>
<template>
<Head title="Tambah Role" />
<AdminLayout>

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RoleForm from './form/RoleForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import RoleForm from './form/RoleForm.vue';
interface PermissionOption {
value: string;
@ -17,14 +17,14 @@ interface RoleData {
permission_names: string[];
}
const props = defineProps<{
defineProps<{
role: RoleData;
permissions: PermissionOption[];
isProtected: boolean;
}>();
</script>
<template>
<Head title="Ubah Role" />
<AdminLayout>
@ -44,6 +44,6 @@ const props = defineProps<{
</div>
<RoleForm :submit-url="`/admin/system/roles/${role.id}`" method="put" submit-label="Perbarui"
:initial-data="role" :permissions="permissions" :is-protected="isProtected" />
:initial-data="role" :permissions="permissions" />
</AdminLayout>
</template>

View File

@ -2,7 +2,6 @@
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { createColumns, type RoleListItem } from './table/columns';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -13,6 +12,8 @@ import {
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import { createColumns } from './table/columns';
import type { RoleListItem } from './table/columns';
interface PaginatedRoles {
current_page: number;
@ -75,6 +76,7 @@ watch(
</script>
<template>
<Head title="Role & Permission" />
<AdminLayout>

View File

@ -1,4 +1,8 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -10,10 +14,6 @@ import {
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { formErrors } from '@/lib/form';
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
interface PermissionOption {
value: string;
@ -31,12 +31,10 @@ const props = withDefaults(
submitUrl: string;
method?: 'post' | 'put';
submitLabel?: string;
isProtected?: boolean;
}>(),
{
method: 'post',
submitLabel: 'Simpan',
isProtected: false,
},
);
@ -45,15 +43,16 @@ const form = useForm({
permissions: props.initialData?.permission_names ?? [],
});
// Group permissions by group/module
const groupedPermissions = computed(() => {
const groups: Record<string, PermissionOption[]> = {};
props.permissions.forEach((perm) => {
if (!groups[perm.group]) {
groups[perm.group] = [];
}
groups[perm.group].push(perm);
});
return groups;
});
@ -71,10 +70,13 @@ function isPermissionChecked(permValue: string): boolean {
return form.permissions.includes(permValue);
}
// Check/Uncheck all for a specific module group
function isGroupAllChecked(groupName: string): boolean {
const groupPerms = groupedPermissions.value[groupName] || [];
if (groupPerms.length === 0) return false;
if (groupPerms.length === 0) {
return false;
}
return groupPerms.every((perm) => form.permissions.includes(perm.value));
}
@ -83,11 +85,9 @@ function toggleGroupAll(groupName: string, checked: boolean) {
const groupValues = groupPerms.map((p) => p.value);
if (checked) {
// Add all group permissions that are not already in form.permissions
const toAdd = groupValues.filter((v) => !form.permissions.includes(v));
form.permissions = [...form.permissions, ...toAdd];
} else {
// Remove all group permissions
form.permissions = form.permissions.filter(
(p) => !groupValues.includes(p),
);
@ -96,11 +96,6 @@ function toggleGroupAll(groupName: string, checked: boolean) {
function submit() {
form[props.method](props.submitUrl, {
onSuccess: () => {
toast.success(
`Role berhasil ${props.method === 'post' ? 'dibuat' : 'diperbarui'}.`,
);
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
@ -119,26 +114,9 @@ function submit() {
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="name" required
>Nama</FieldLabel
>
<Input
id="name"
v-model="form.name"
type="text"
placeholder="Contoh: admin-gudang"
:disabled="isProtected"
/>
<span
v-if="isProtected"
class="mt-1 text-xs text-muted-foreground"
>
Nama bawaan sistem (developer/owner) tidak
dapat diubah.
</span>
<FieldError
:errors="formErrors(form, 'name')"
/>
<FieldLabel for="name" required>Nama</FieldLabel>
<Input id="name" v-model="form.name" type="text" placeholder="Contoh: admin-gudang" />
<FieldError :errors="formErrors(form, 'name')" />
</Field>
</FieldSet>
</FieldGroup>
@ -151,63 +129,42 @@ function submit() {
</CardHeader>
<CardContent>
<div class="space-y-6">
<div
v-for="(perms, groupName) in groupedPermissions"
:key="groupName"
class="border-b pb-6 last:border-b-0 last:pb-0"
>
<div v-for="(perms, groupName) in groupedPermissions" :key="groupName"
class="border-b pb-6 last:border-b-0 last:pb-0">
<div class="mb-3 flex items-center justify-between">
<h3
class="text-base font-semibold text-foreground"
>
<h3 class="text-base font-semibold text-foreground">
{{ groupName }}
</h3>
<label
class="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<input
type="checkbox"
class="size-3.5 cursor-pointer rounded border-input"
:checked="isGroupAllChecked(groupName)"
@change="
class="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
<input type="checkbox" class="size-3.5 cursor-pointer rounded border-input"
:checked="isGroupAllChecked(groupName)" @change="
toggleGroupAll(
groupName,
(
$event.target as HTMLInputElement
).checked,
)
"
/>
" />
<span>Pilih Semua</span>
</label>
</div>
<div
class="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<label
v-for="perm in perms"
:key="perm.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 transition-colors hover:bg-muted/50"
>
<input
type="checkbox"
class="size-4 cursor-pointer rounded border-input"
:checked="
isPermissionChecked(perm.value)
"
@change="
<div class="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<label v-for="perm in perms" :key="perm.value"
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 transition-colors hover:bg-muted/50">
<input type="checkbox" class="size-4 cursor-pointer rounded border-input" :checked="isPermissionChecked(perm.value)
" @change="
togglePermission(
perm.value,
(
$event.target as HTMLInputElement
).checked,
)
"
/>
" />
<span class="text-sm">{{
perm.label
}}</span>
}}</span>
</label>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { DataTableColumnHeader } from '@/components/data-table';
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table';
import DataTableActions from './data-table-actions.vue';
export interface RoleListItem {

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
@ -18,23 +18,13 @@ const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const isSystemRole = computed(() => {
return ['developer', 'owner'].includes(props.role.name);
});
function destroyRole() {
if (isSystemRole.value) {
toast.error('Role sistem tidak dapat dihapus.');
return;
}
deleteProcessing.value = true;
router.delete(`/admin/system/roles/${props.role.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
toast.success('Role berhasil dihapus.');
},
onError: () => {
toast.error('Gagal menghapus role.');
@ -63,9 +53,7 @@ function destroyRole() {
<Tooltip v-if="can('roles.delete')">
<TooltipTrigger as-child>
<span>
<Button variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-8"
:disabled="isSystemRole"
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
@ -73,7 +61,7 @@ function destroyRole() {
</span>
</TooltipTrigger>
<TooltipContent>
{{ isSystemRole ? 'Role sistem tidak dapat dihapus' : 'Hapus' }}
Hapus
</TooltipContent>
</Tooltip>
</div>

View File

@ -1,11 +1,6 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import HomepageSection from './form/HomepageSection.vue';
import HrSection from './form/HrSection.vue';
import MarketplaceSection from './form/MarketplaceSection.vue';
import SocialMediaSection from './form/SocialMediaSection.vue';
import SystemSection from './form/SystemSection.vue';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
import SettingLayout from '@/layouts/SettingLayout.vue';
import type {
@ -16,6 +11,11 @@ import type {
SocialMediaSettingsData,
SystemSettingsData,
} from '@/types/setting';
import HomepageSection from './form/HomepageSection.vue';
import HrSection from './form/HrSection.vue';
import MarketplaceSection from './form/MarketplaceSection.vue';
import SocialMediaSection from './form/SocialMediaSection.vue';
import SystemSection from './form/SystemSection.vue';
defineProps<{
system: SystemSettingsData;

View File

@ -8,8 +8,8 @@ import MultipleImageUploadField from '@/components/form/image-upload-field/Multi
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { createMediaUploadState } from '@/types/media';
import type { HomepageSettingsData } from '@/types/setting';
import type { MediaUploadState } from '@/types/media';
import type { HomepageSettingsData } from '@/types/setting';
const props = defineProps<{
data: HomepageSettingsData;
@ -46,7 +46,6 @@ function submit() {
router.put('/admin/system/setting/homepage', formData, {
preserveScroll: true,
onSuccess: () => {
toast.success('Pengaturan homepage berhasil disimpan.');
heroImage.value = null;
aboutImage.value = null;
},
@ -64,44 +63,29 @@ function submit() {
<form @submit.prevent="submit">
<div class="grid gap-4">
<!-- Hero Image -->
<Card>
<CardContent>
<ImageUploadField
id="hero_image"
label="Foto Hero"
<ImageUploadField id="hero_image" label="Foto Hero"
description="Foto utama yang ditampilkan di bagian hero homepage."
:current-url="props.data.hero_image_url"
v-model="heroImage"
preview-class="aspect-[3/4] max-w-xs"
/>
:current-url="props.data.hero_image_url" v-model="heroImage"
preview-class="aspect-[3/4] max-w-xs" />
</CardContent>
</Card>
<!-- Gallery / Lookbook -->
<Card>
<CardContent>
<MultipleImageUploadField
id="gallery_images"
label="Koleksi Lookbook"
description="Foto-foto yang ditampilkan di galeri lookbook."
:max-files="10"
v-model="galleryState"
/>
<MultipleImageUploadField id="gallery_images" label="Koleksi Lookbook"
description="Foto-foto yang ditampilkan di galeri lookbook." :max-files="10"
v-model="galleryState" />
</CardContent>
</Card>
<!-- About Image -->
<Card>
<CardContent>
<ImageUploadField
id="about_image"
label="Foto Tentang Kami"
<ImageUploadField id="about_image" label="Foto Tentang Kami"
description="Foto yang ditampilkan di bagian tentang kami."
:current-url="props.data.about_image_url"
v-model="aboutImage"
preview-class="aspect-video max-w-md"
/>
:current-url="props.data.about_image_url" v-model="aboutImage"
preview-class="aspect-video max-w-md" />
</CardContent>
</Card>

View File

@ -67,7 +67,8 @@ function submit() {
<Field>
<FieldLabel for="absent_penalty_amount">Denda Bolos</FieldLabel>
<RupiahInput id="absent_penalty_amount" v-model="form.absent_penalty_amount" placeholder="0" />
<RupiahInput id="absent_penalty_amount" v-model="form.absent_penalty_amount"
placeholder="0" />
<p class="text-xs text-muted-foreground mt-1">
Potongan gaji otomatis jika pegawai tidak melakukan presensi masuk dan pulang (bolos).
</p>

View File

@ -56,10 +56,8 @@ function updateValue(value: string | number) {
<FieldLabel :for="`${id}-scope`" class="text-xs text-muted-foreground">
Dasar
</FieldLabel>
<Select
:model-value="modelValue.scope"
@update:model-value="update({ scope: $event as MarketplaceFeeRule['scope'] })"
>
<Select :model-value="modelValue.scope"
@update:model-value="update({ scope: $event as MarketplaceFeeRule['scope'] })">
<SelectTrigger :id="`${id}-scope`">
<SelectValue placeholder="Pilih dasar" />
</SelectTrigger>
@ -78,10 +76,8 @@ function updateValue(value: string | number) {
<FieldLabel :for="`${id}-value-type`" class="text-xs text-muted-foreground">
Tipe
</FieldLabel>
<Select
:model-value="modelValue.value_type"
@update:model-value="update({ value_type: $event as MarketplaceFeeRule['value_type'] })"
>
<Select :model-value="modelValue.value_type"
@update:model-value="update({ value_type: $event as MarketplaceFeeRule['value_type'] })">
<SelectTrigger :id="`${id}-value-type`">
<SelectValue placeholder="Pilih tipe" />
</SelectTrigger>
@ -100,23 +96,10 @@ function updateValue(value: string | number) {
<FieldLabel :for="`${id}-value`" class="text-xs text-muted-foreground">
Nilai
</FieldLabel>
<RupiahInput
v-if="!isPercent"
:id="`${id}-value`"
:model-value="modelValue.value"
placeholder="0"
@update:model-value="updateValue"
/>
<Input
v-else
:id="`${id}-value`"
:model-value="modelValue.value"
type="number"
step="0.01"
min="0"
max="100"
@update:model-value="updateValue"
/>
<RupiahInput v-if="!isPercent" :id="`${id}-value`" :model-value="modelValue.value" placeholder="0"
@update:model-value="updateValue" />
<Input v-else :id="`${id}-value`" :model-value="modelValue.value" type="number" step="0.01" min="0"
max="100" @update:model-value="updateValue" />
</Field>
</div>

View File

@ -3,7 +3,6 @@ import { useForm } from '@inertiajs/vue3';
import { Save, ShoppingBag, Store } from '@lucide/vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import MarketplaceFeeField from './MarketplaceFeeField.vue';
import { Button } from '@/components/ui/button';
import {
Card,
@ -20,6 +19,7 @@ import type {
MarketplacePlatform,
MarketplaceSettingsData,
} from '@/types/setting';
import MarketplaceFeeField from './MarketplaceFeeField.vue';
const props = defineProps<{
data: MarketplaceSettingsData;
@ -93,47 +93,28 @@ function submit() {
<CardContent>
<FieldSet>
<FieldGroup class="grid gap-4 sm:grid-cols-2">
<MarketplaceFeeField
id="tiktok_shop_platform_commission"
v-model="form.tiktok_shop_platform_commission"
label="Biaya Komisi Platform"
:errors="feeErrors('tiktok_shop_platform_commission')"
/>
<MarketplaceFeeField id="tiktok_shop_platform_commission"
v-model="form.tiktok_shop_platform_commission" label="Biaya Komisi Platform"
:errors="feeErrors('tiktok_shop_platform_commission')" />
<MarketplaceFeeField
id="tiktok_shop_logistics_service_fee"
v-model="form.tiktok_shop_logistics_service_fee"
label="Biaya Layanan Logistik"
:errors="feeErrors('tiktok_shop_logistics_service_fee')"
/>
<MarketplaceFeeField id="tiktok_shop_logistics_service_fee"
v-model="form.tiktok_shop_logistics_service_fee" label="Biaya Layanan Logistik"
:errors="feeErrors('tiktok_shop_logistics_service_fee')" />
<MarketplaceFeeField
id="tiktok_shop_dynamic_commission"
v-model="form.tiktok_shop_dynamic_commission"
label="Komisi Dinamis"
:errors="feeErrors('tiktok_shop_dynamic_commission')"
/>
<MarketplaceFeeField id="tiktok_shop_dynamic_commission"
v-model="form.tiktok_shop_dynamic_commission" label="Komisi Dinamis"
:errors="feeErrors('tiktok_shop_dynamic_commission')" />
<MarketplaceFeeField
id="tiktok_shop_order_processing_fee"
v-model="form.tiktok_shop_order_processing_fee"
label="Biaya Pemrosesan Pesanan"
:errors="feeErrors('tiktok_shop_order_processing_fee')"
/>
<MarketplaceFeeField id="tiktok_shop_order_processing_fee"
v-model="form.tiktok_shop_order_processing_fee" label="Biaya Pemrosesan Pesanan"
:errors="feeErrors('tiktok_shop_order_processing_fee')" />
<MarketplaceFeeField
id="tiktok_shop_affiliate"
v-model="form.tiktok_shop_affiliate"
label="Affiliate"
:errors="feeErrors('tiktok_shop_affiliate')"
/>
<MarketplaceFeeField id="tiktok_shop_affiliate" v-model="form.tiktok_shop_affiliate"
label="Affiliate" :errors="feeErrors('tiktok_shop_affiliate')" />
<MarketplaceFeeField
id="tiktok_shop_pre_order_service_fee"
v-model="form.tiktok_shop_pre_order_service_fee"
label="Biaya Layanan Pre Order"
:errors="feeErrors('tiktok_shop_pre_order_service_fee')"
/>
<MarketplaceFeeField id="tiktok_shop_pre_order_service_fee"
v-model="form.tiktok_shop_pre_order_service_fee" label="Biaya Layanan Pre Order"
:errors="feeErrors('tiktok_shop_pre_order_service_fee')" />
</FieldGroup>
</FieldSet>
</CardContent>
@ -143,68 +124,34 @@ function submit() {
<CardContent>
<FieldSet>
<FieldGroup class="grid gap-4 sm:grid-cols-2">
<MarketplaceFeeField
id="shopee_admin_fee"
v-model="form.shopee_admin_fee"
label="Biaya Administrasi"
:errors="feeErrors('shopee_admin_fee')"
/>
<MarketplaceFeeField id="shopee_admin_fee" v-model="form.shopee_admin_fee"
label="Biaya Administrasi" :errors="feeErrors('shopee_admin_fee')" />
<MarketplaceFeeField
id="shopee_program_fee"
v-model="form.shopee_program_fee"
label="Biaya Program"
:errors="feeErrors('shopee_program_fee')"
/>
<MarketplaceFeeField id="shopee_program_fee" v-model="form.shopee_program_fee"
label="Biaya Program" :errors="feeErrors('shopee_program_fee')" />
<MarketplaceFeeField
id="shopee_shipping_savings"
v-model="form.shopee_shipping_savings"
label="Hemat Biaya Kirim"
:errors="feeErrors('shopee_shipping_savings')"
/>
<MarketplaceFeeField id="shopee_shipping_savings" v-model="form.shopee_shipping_savings"
label="Hemat Biaya Kirim" :errors="feeErrors('shopee_shipping_savings')" />
<MarketplaceFeeField
id="shopee_premium"
v-model="form.shopee_premium"
label="Premi"
:errors="feeErrors('shopee_premium')"
/>
<MarketplaceFeeField id="shopee_premium" v-model="form.shopee_premium" label="Premi"
:errors="feeErrors('shopee_premium')" />
<MarketplaceFeeField
id="shopee_service_fee"
v-model="form.shopee_service_fee"
label="Biaya Layanan"
:errors="feeErrors('shopee_service_fee')"
/>
<MarketplaceFeeField id="shopee_service_fee" v-model="form.shopee_service_fee"
label="Biaya Layanan" :errors="feeErrors('shopee_service_fee')" />
<MarketplaceFeeField
id="shopee_order_processing_fee"
v-model="form.shopee_order_processing_fee"
label="Biaya Proses Pesanan"
:errors="feeErrors('shopee_order_processing_fee')"
/>
<MarketplaceFeeField id="shopee_order_processing_fee"
v-model="form.shopee_order_processing_fee" label="Biaya Proses Pesanan"
:errors="feeErrors('shopee_order_processing_fee')" />
<MarketplaceFeeField
id="shopee_ams_commission_fee"
v-model="form.shopee_ams_commission_fee"
label="Biaya Komisi AMS"
:errors="feeErrors('shopee_ams_commission_fee')"
/>
<MarketplaceFeeField id="shopee_ams_commission_fee"
v-model="form.shopee_ams_commission_fee" label="Biaya Komisi AMS"
:errors="feeErrors('shopee_ams_commission_fee')" />
<MarketplaceFeeField
id="shopee_pre_order"
v-model="form.shopee_pre_order"
label="Pre Order"
:errors="feeErrors('shopee_pre_order')"
/>
<MarketplaceFeeField id="shopee_pre_order" v-model="form.shopee_pre_order"
label="Pre Order" :errors="feeErrors('shopee_pre_order')" />
<MarketplaceFeeField
id="shopee_live_extra"
v-model="form.shopee_live_extra"
label="Live Extra"
:errors="feeErrors('shopee_live_extra')"
/>
<MarketplaceFeeField id="shopee_live_extra" v-model="form.shopee_live_extra"
label="Live Extra" :errors="feeErrors('shopee_live_extra')" />
</FieldGroup>
</FieldSet>
</CardContent>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3';
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { ImageUploadField } from '@/components/form/image-upload-field';
@ -56,9 +56,6 @@ function submit() {
form.transform(() => payload).post('/admin/system/setting/system', {
preserveScroll: true,
onSuccess: () => {
router.reload({ only: ['name', 'address'] });
},
onError: () => {
toast.error('Gagal menyimpan pengaturan sistem.');
},
@ -67,14 +64,6 @@ function submit() {
</script>
<template>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
</h2>
</div>
</div>
<form @submit.prevent="submit">
<div class="grid gap-6">
<Card>

View File

@ -328,18 +328,23 @@
->middleware('permission:'.Permission::ROLES_VIEW->value)
->group(function () {
Route::get('/', [RoleController::class, 'index'])->name('index');
Route::get('create', [RoleController::class, 'create'])
->middleware('permission:'.Permission::ROLES_CREATE->value)
->name('create');
Route::post('/', [RoleController::class, 'store'])
->middleware('permission:'.Permission::ROLES_CREATE->value)
->name('store');
Route::get('{role}/edit', [RoleController::class, 'edit'])
->middleware('permission:'.Permission::ROLES_UPDATE->value)
->name('edit');
Route::put('{role}', [RoleController::class, 'update'])
->middleware('permission:'.Permission::ROLES_UPDATE->value)
->name('update');
Route::delete('{role}', [RoleController::class, 'destroy'])
->middleware('permission:'.Permission::ROLES_DELETE->value)
->name('destroy');
@ -356,7 +361,7 @@
->group(function () {
Route::get('/', [SettingController::class, 'index'])->name('index');
Route::post('system', [SettingController::class, 'updateSystem'])
Route::put('system', [SettingController::class, 'updateSystem'])
->middleware('permission:'.Permission::SETTINGS_UPDATE->value)
->name('system.update');

View File

@ -0,0 +1,243 @@
<?php
use App\Enums\Permission as PermissionEnum;
use App\Models\Category;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
// ─── Helper ───────────────────────────────────────────────
function createActivityLogUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function createActivityLogs(User $causer, int $count = 3): void
{
for ($i = 0; $i < $count; $i++) {
$category = Category::factory()->create(['name' => "Kategori {$i}"]);
activity()
->causedBy($causer)
->performedOn($category)
->event('created')
->withProperties(['attributes' => ['name' => "Kategori {$i}"]])
->log("Kategori {$i} ditambahkan");
}
}
// ─── Index ────────────────────────────────────────────────
describe('Activity Log Index', function () {
test('authenticated user with permission can view activity log index', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.system.activity_logs.index'))
->assertRedirect(route('login'));
});
test('user without permission is forbidden', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.system.activity_logs.index'))
->assertForbidden();
});
test('index displays activity logs', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
createActivityLogs($user, 3);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index'))
->assertOk();
});
test('index returns pagination data', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
createActivityLogs($user, 15);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index'))
->assertOk();
});
test('index can search activity logs by description', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
activity()
->causedBy($user)
->event('created')
->log('Produk baru ditambahkan');
activity()
->causedBy($user)
->event('updated')
->log('Supplier diperbarui');
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['search' => 'Produk']))
->assertOk();
});
test('index can filter by event type', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
activity()->causedBy($user)->event('created')->log('Test created');
activity()->causedBy($user)->event('updated')->log('Test updated');
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['event' => 'created']))
->assertOk();
});
test('index can filter by subject type', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
Category::factory()->create();
$user->update(['email' => 'new@example.com']);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['subject_type' => Category::class]))
->assertOk();
});
test('index can sort by created_at', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
createActivityLogs($user, 3);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['sort' => 'created_at', 'direction' => 'asc']))
->assertOk();
});
test('index can sort by event', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
createActivityLogs($user, 3);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['sort' => 'event', 'direction' => 'desc']))
->assertOk();
});
test('index can sort by description', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
createActivityLogs($user, 3);
$this->actingAs($user)
->get(route('admin.system.activity_logs.index', ['sort' => 'description', 'direction' => 'asc']))
->assertOk();
});
});
// ─── Activity Log Creation via Model ──────────────────────
describe('Activity Log Creation', function () {
test('activity log is created when category is created', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::CATEGORIES_VIEW, PermissionEnum::CATEGORIES_CREATE);
$this->actingAs($user)
->post(route('admin.master.categories.store'), [
'name' => 'Test Kategori',
]);
$this->assertDatabaseHas('activity_log', [
'event' => 'created',
'subject_type' => Category::class,
]);
});
test('activity log is created when category is updated', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::CATEGORIES_VIEW, PermissionEnum::CATEGORIES_UPDATE);
$category = Category::factory()->create();
$this->actingAs($user)
->put(route('admin.master.categories.update', $category), [
'name' => 'Updated Name',
]);
$this->assertDatabaseHas('activity_log', [
'event' => 'updated',
'subject_type' => Category::class,
'subject_id' => $category->id,
]);
});
test('activity log is created when category is deleted', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::CATEGORIES_VIEW, PermissionEnum::CATEGORIES_DELETE);
$category = Category::factory()->create();
$this->actingAs($user)
->delete(route('admin.master.categories.destroy', $category));
$this->assertDatabaseHas('activity_log', [
'event' => 'deleted',
'subject_type' => Category::class,
'subject_id' => $category->id,
]);
});
test('activity log stores causer information', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
activity()
->causedBy($user)
->event('created')
->log('Test log entry');
$activity = Activity::latest()->first();
expect($activity->causer_type)->toBe(User::class);
expect($activity->causer_id)->toBe($user->id);
});
test('activity log stores properties correctly', function () {
$user = createActivityLogUserWithPermission(PermissionEnum::ACTIVITY_LOGS_VIEW);
activity()
->causedBy($user)
->event('updated')
->withProperties([
'attributes' => ['name' => 'New Value'],
'old' => ['name' => 'Old Value'],
])
->log('Test with properties');
$activity = Activity::latest()->first();
expect($activity->properties->get('attributes')['name'])->toBe('New Value');
expect($activity->properties->get('old')['name'])->toBe('Old Value');
});
});

View File

@ -0,0 +1,418 @@
<?php
use App\Enums\Permission as PermissionEnum;
use App\Enums\Role as RoleEnum;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
});
// ─── Helper ───────────────────────────────────────────────
function createRoleUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
// ─── Index ────────────────────────────────────────────────
describe('Role Index', function () {
test('authenticated user with permission can view role index', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$this->actingAs($user)
->get(route('admin.system.roles.index'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.system.roles.index'))
->assertRedirect(route('login'));
});
test('user without permission is forbidden', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.system.roles.index'))
->assertForbidden();
});
test('index displays all seeded roles', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$this->actingAs($user)
->get(route('admin.system.roles.index'))
->assertOk();
});
test('index can search roles by name', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$this->actingAs($user)
->get(route('admin.system.roles.index', ['search' => 'developer']))
->assertOk();
});
});
// ─── Create ───────────────────────────────────────────────
describe('Role Create', function () {
test('authenticated user with permission can view create form', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->get(route('admin.system.roles.create'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.system.roles.create'))
->assertRedirect(route('login'));
});
test('user without create permission is forbidden', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$this->actingAs($user)
->get(route('admin.system.roles.create'))
->assertForbidden();
});
});
// ─── Store ────────────────────────────────────────────────
describe('Role Store', function () {
test('authenticated user with permission can create a role', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => 'Custom Role',
'permissions' => [PermissionEnum::DASHBOARD_VIEW->value, PermissionEnum::CATEGORIES_VIEW->value],
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'name' => 'custom-role',
]);
$role = Role::findByName('custom-role');
expect($role->permissions->pluck('name')->toArray())
->toContain(PermissionEnum::DASHBOARD_VIEW->value, PermissionEnum::CATEGORIES_VIEW->value);
});
test('guest cannot create a role', function () {
$this->post(route('admin.system.roles.store'), [
'name' => 'New Role',
])->assertRedirect(route('login'));
});
test('user without create permission is forbidden', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => 'New Role',
])
->assertForbidden();
});
test('name is required', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => '',
])
->assertSessionHasErrors('name');
});
test('name must not exceed 50 characters', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => str_repeat('a', 51),
])
->assertSessionHasErrors('name');
});
test('name must be unique', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => RoleEnum::DEVELOPER->value,
])
->assertSessionHasErrors('name');
});
test('name is slugified before saving', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => 'My Custom Role',
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'name' => 'my-custom-role',
]);
});
test('permissions must exist in database', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => 'new-role',
'permissions' => ['nonexistent.permission'],
])
->assertSessionHasErrors('permissions.0');
});
test('role can be created without permissions', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_CREATE);
$this->actingAs($user)
->post(route('admin.system.roles.store'), [
'name' => 'empty-role',
'permissions' => [],
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'name' => 'empty-role',
]);
$role = Role::findByName('empty-role');
expect($role->permissions)->toHaveCount(0);
});
});
// ─── Edit ─────────────────────────────────────────────────
describe('Role Edit', function () {
test('authenticated user with permission can view edit form', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::findByName(RoleEnum::ADMIN_TOKO->value);
$this->actingAs($user)
->get(route('admin.system.roles.edit', $role))
->assertOk();
});
test('guest is redirected to login', function () {
$role = Role::findByName(RoleEnum::ADMIN_TOKO->value);
$this->get(route('admin.system.roles.edit', $role))
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$role = Role::findByName(RoleEnum::ADMIN_TOKO->value);
$this->actingAs($user)
->get(route('admin.system.roles.edit', $role))
->assertForbidden();
});
test('edit page shows role data with permissions', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::findByName(RoleEnum::ADMIN_TOKO->value);
$this->actingAs($user)
->get(route('admin.system.roles.edit', $role))
->assertOk();
});
test('protected roles are flagged as protected', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::findByName(RoleEnum::DEVELOPER->value);
$this->actingAs($user)
->get(route('admin.system.roles.edit', $role))
->assertOk();
});
});
// ─── Update ───────────────────────────────────────────────
describe('Role Update', function () {
test('authenticated user with permission can update a role', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'updatable-role', 'guard_name' => 'web']);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'updated-role-name',
'permissions' => [PermissionEnum::DASHBOARD_VIEW->value],
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'id' => $role->id,
'name' => 'updated-role-name',
]);
$role->refresh();
expect($role->permissions->pluck('name')->toArray())
->toContain(PermissionEnum::DASHBOARD_VIEW->value);
});
test('guest cannot update a role', function () {
$role = Role::create(['name' => 'test-role', 'guard_name' => 'web']);
$this->put(route('admin.system.roles.update', $role), [
'name' => 'new-name',
])->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW);
$role = Role::create(['name' => 'test-role', 'guard_name' => 'web']);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'new-name',
])
->assertForbidden();
});
test('name is required on update', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'test-role', 'guard_name' => 'web']);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => '',
])
->assertSessionHasErrors('name');
});
test('name must be unique on update', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'test-role', 'guard_name' => 'web']);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => RoleEnum::OWNER->value,
])
->assertSessionHasErrors('name');
});
test('name can stay the same on update', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'test-role', 'guard_name' => 'web']);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'test-role',
'permissions' => [],
])
->assertRedirect(route('admin.system.roles.index'));
});
test('protected role name cannot be changed', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::findByName(RoleEnum::DEVELOPER->value);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'renamed-developer',
'permissions' => $role->permissions->pluck('name')->toArray(),
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'id' => $role->id,
'name' => RoleEnum::DEVELOPER->value,
]);
});
test('owner role name cannot be changed', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::findByName(RoleEnum::OWNER->value);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'renamed-owner',
'permissions' => $role->permissions->pluck('name')->toArray(),
])
->assertRedirect(route('admin.system.roles.index'));
$this->assertDatabaseHas('roles', [
'id' => $role->id,
'name' => RoleEnum::OWNER->value,
]);
});
test('role permissions can be updated', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'perm-test-role', 'guard_name' => 'web']);
$role->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'perm-test-role',
'permissions' => [PermissionEnum::CATEGORIES_VIEW->value, PermissionEnum::ORDERS_VIEW->value],
])
->assertRedirect(route('admin.system.roles.index'));
$role->refresh();
$permissionNames = $role->permissions->pluck('name')->toArray();
expect($permissionNames)->toContain(PermissionEnum::CATEGORIES_VIEW->value);
expect($permissionNames)->toContain(PermissionEnum::ORDERS_VIEW->value);
expect($permissionNames)->not->toContain(PermissionEnum::DASHBOARD_VIEW->value);
});
test('all permissions can be removed from role', function () {
$user = createRoleUserWithPermission(PermissionEnum::ROLES_VIEW, PermissionEnum::ROLES_UPDATE);
$role = Role::create(['name' => 'clear-perm-role', 'guard_name' => 'web']);
$role->givePermissionTo(PermissionEnum::DASHBOARD_VIEW->value);
$this->actingAs($user)
->put(route('admin.system.roles.update', $role), [
'name' => 'clear-perm-role',
'permissions' => [],
])
->assertRedirect(route('admin.system.roles.index'));
$role->refresh();
expect($role->permissions)->toHaveCount(0);
});
});

View File

@ -0,0 +1,616 @@
<?php
use App\Enums\Permission as PermissionEnum;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->seed(RolePermissionSeeder::class);
Storage::fake('public');
});
// ─── Helper ───────────────────────────────────────────────
function createSettingUserWithPermission(PermissionEnum ...$permissions): User
{
$user = User::factory()->create();
$user->givePermissionTo(
array_merge(
[PermissionEnum::DASHBOARD_VIEW->value],
array_map(fn (PermissionEnum $p) => $p->value, $permissions)
)
);
$user->forgetCachedPermissions();
return $user;
}
function validSystemPayload(): array
{
return [
'app_name' => 'DST Collection',
'about_app' => 'Tentang aplikasi kami',
'email' => 'info@dstcollection.com',
'phone' => '081234567890',
'address' => 'Jl. Merdeka No. 1',
];
}
function validMarketplaceFeeRule(): array
{
return [
'scope' => 'product',
'value_type' => 'percent',
'value' => 5.0,
];
}
function allMarketplaceFeeFields(): array
{
$fields = [
'tiktok_shop_platform_commission',
'tiktok_shop_logistics_service_fee',
'tiktok_shop_dynamic_commission',
'tiktok_shop_order_processing_fee',
'tiktok_shop_affiliate',
'tiktok_shop_pre_order_service_fee',
'shopee_admin_fee',
'shopee_program_fee',
'shopee_shipping_savings',
'shopee_premium',
'shopee_service_fee',
'shopee_order_processing_fee',
'shopee_ams_commission_fee',
'shopee_pre_order',
'shopee_live_extra',
];
return collect($fields)->mapWithKeys(fn (string $key) => [$key => validMarketplaceFeeRule()])->all();
}
// ─── Index ────────────────────────────────────────────────
describe('Setting Index', function () {
test('authenticated user with permission can view settings page', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->get(route('admin.system.settings.index'))
->assertOk();
});
test('guest is redirected to login', function () {
$this->get(route('admin.system.settings.index'))
->assertRedirect(route('login'));
});
test('user without permission is forbidden', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.system.settings.index'))
->assertForbidden();
});
});
// ─── Update System ────────────────────────────────────────
describe('Setting Update System', function () {
test('authenticated user with permission can update system settings', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['logo'] = UploadedFile::fake()->image('logo.jpg', 100, 100);
$payload['favicon'] = UploadedFile::fake()->image('favicon.png', 32, 32);
$payload['login_cover'] = UploadedFile::fake()->image('login-cover.jpg', 800, 600);
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertRedirect(route('admin.system.settings.index'));
$this->assertDatabaseHas('settings', [
'group' => 'system',
'name' => 'app_name',
]);
});
test('guest cannot update system settings', function () {
$this->put(route('admin.system.settings.system.update'), validSystemPayload())
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), validSystemPayload())
->assertForbidden();
});
test('app_name is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['app_name'] = '';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('app_name');
});
test('app_name must not exceed 100 characters', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['app_name'] = str_repeat('a', 101);
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('app_name');
});
test('email must be valid', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['email'] = 'not-an-email';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('email');
});
test('email is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['email'] = '';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('email');
});
test('phone must be valid phone number', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['phone'] = '123invalid';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('phone');
});
test('phone is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['phone'] = '';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('phone');
});
test('about_app is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['about_app'] = '';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('about_app');
});
test('address is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['address'] = '';
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('address');
});
test('logo must be an image when provided', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['logo'] = UploadedFile::fake()->create('document.pdf', 100);
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('logo');
});
test('favicon must be an image when provided', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = validSystemPayload();
$payload['favicon'] = UploadedFile::fake()->create('document.pdf', 100);
$this->actingAs($user)
->put(route('admin.system.settings.system.update'), $payload)
->assertSessionHasErrors('favicon');
});
});
// ─── Update Social Media ──────────────────────────────────
describe('Setting Update Social Media', function () {
test('authenticated user can update social media settings', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'instagram_url' => 'https://instagram.com/dstcollection',
'facebook_url' => 'https://facebook.com/dstcollection',
'tiktok_url' => 'https://tiktok.com/@dstcollection',
])
->assertRedirect(route('admin.system.settings.index'));
$this->assertDatabaseHas('settings', [
'group' => 'social_media',
'name' => 'instagram_url',
]);
});
test('guest cannot update social media settings', function () {
$this->put(route('admin.system.settings.social_media.update'), [
'instagram_url' => 'https://instagram.com/test',
])->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'instagram_url' => 'https://instagram.com/test',
])
->assertForbidden();
});
test('social media urls can be null', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'instagram_url' => null,
'facebook_url' => null,
'tiktok_url' => null,
])
->assertRedirect(route('admin.system.settings.index'));
$this->assertDatabaseHas('settings', [
'group' => 'social_media',
'name' => 'instagram_url',
]);
});
test('instagram_url must be a valid url', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'instagram_url' => 'not-a-url',
])
->assertSessionHasErrors('instagram_url');
});
test('facebook_url must be a valid url', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'facebook_url' => 'not-a-url',
])
->assertSessionHasErrors('facebook_url');
});
test('tiktok_url must be a valid url', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.social_media.update'), [
'tiktok_url' => 'not-a-url',
])
->assertSessionHasErrors('tiktok_url');
});
});
// ─── Update Marketplace ───────────────────────────────────
describe('Setting Update Marketplace', function () {
test('authenticated user can update marketplace settings', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertRedirect(route('admin.system.settings.index'));
$this->assertDatabaseHas('settings', [
'group' => 'marketplace',
'name' => 'tiktok_shop_platform_commission',
]);
});
test('guest cannot update marketplace settings', function () {
$this->put(route('admin.system.settings.marketplace.update'), allMarketplaceFeeFields())
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), allMarketplaceFeeFields())
->assertForbidden();
});
test('marketplace fee scope must be valid enum', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$payload['tiktok_shop_platform_commission']['scope'] = 'invalid';
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertSessionHasErrors('tiktok_shop_platform_commission.scope');
});
test('marketplace fee value_type must be valid enum', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$payload['tiktok_shop_platform_commission']['value_type'] = 'invalid';
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertSessionHasErrors('tiktok_shop_platform_commission.value_type');
});
test('marketplace fee value must be numeric', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$payload['tiktok_shop_platform_commission']['value'] = 'not-a-number';
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace fee percent value must not exceed 100', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$payload['tiktok_shop_platform_commission']['value_type'] = 'percent';
$payload['tiktok_shop_platform_commission']['value'] = 150.0;
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertSessionHasErrors('tiktok_shop_platform_commission.value');
});
test('marketplace flat fee must not have decimals', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$payload = allMarketplaceFeeFields();
$payload['shopee_admin_fee']['value_type'] = 'flat';
$payload['shopee_admin_fee']['value'] = 1500.5;
$this->actingAs($user)
->put(route('admin.system.settings.marketplace.update'), $payload)
->assertSessionHasErrors('shopee_admin_fee.value');
});
});
// ─── Update HR ────────────────────────────────────────────
describe('Setting Update HR', function () {
test('authenticated user can update HR settings', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 50000,
'absent_penalty_amount' => 100000,
])
->assertRedirect(route('admin.system.settings.index'));
$this->assertDatabaseHas('settings', [
'group' => 'hr',
'name' => 'scheduled_check_in_time',
]);
$this->assertDatabaseHas('settings', [
'group' => 'hr',
'name' => 'late_penalty_amount',
]);
});
test('guest cannot update HR settings', function () {
$this->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
])->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
])
->assertForbidden();
});
test('scheduled_check_in_time is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
])
->assertSessionHasErrors('scheduled_check_in_time');
});
test('scheduled_check_in_time must match HH:MM format', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '9:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
])
->assertSessionHasErrors('scheduled_check_in_time');
});
test('scheduled_check_out_time is required', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 0,
])
->assertSessionHasErrors('scheduled_check_out_time');
});
test('late_penalty_amount must be integer', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 'not-a-number',
'absent_penalty_amount' => 0,
])
->assertSessionHasErrors('late_penalty_amount');
});
test('late_penalty_amount must not be negative', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => -1000,
'absent_penalty_amount' => 0,
])
->assertSessionHasErrors('late_penalty_amount');
});
test('absent_penalty_amount must be integer', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.hr.update'), [
'scheduled_check_in_time' => '09:00',
'scheduled_check_out_time' => '18:00',
'late_penalty_amount' => 0,
'absent_penalty_amount' => 'not-a-number',
])
->assertSessionHasErrors('absent_penalty_amount');
});
});
// ─── Update Homepage ──────────────────────────────────────
describe('Setting Update Homepage', function () {
test('authenticated user can update homepage settings', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [])
->assertRedirect(route('admin.system.settings.index'));
});
test('guest cannot update homepage settings', function () {
$this->put(route('admin.system.settings.homepage.update'), [])
->assertRedirect(route('login'));
});
test('user without update permission is forbidden', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW);
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [])
->assertForbidden();
});
test('hero_image must be an image when provided', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [
'hero_image' => UploadedFile::fake()->create('document.pdf', 100),
])
->assertSessionHasErrors('hero_image');
});
test('about_image must be an image when provided', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [
'about_image' => UploadedFile::fake()->create('document.pdf', 100),
])
->assertSessionHasErrors('about_image');
});
test('gallery_images must be an array of images', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [
'gallery_images' => ['not-a-file'],
])
->assertSessionHasErrors('gallery_images.0');
});
test('gallery_images must not exceed 10 items', function () {
$user = createSettingUserWithPermission(PermissionEnum::SETTINGS_VIEW, PermissionEnum::SETTINGS_UPDATE);
$images = [];
for ($i = 0; $i < 11; $i++) {
$images[] = UploadedFile::fake()->image("photo-{$i}.jpg");
}
$this->actingAs($user)
->put(route('admin.system.settings.homepage.update'), [
'gallery_images' => $images,
])
->assertSessionHasErrors('gallery_images');
});
});