Compare commits

...

3 Commits

Author SHA1 Message Date
Yoga Pangestu
23961969f4 feat: implement role and permission management with CRUD functionality 2026-08-30 23:47:58 +07:00
Yoga Pangestu
acda85b4e5 feat: implement log management system with viewing and downloading capabilities 2026-08-30 23:39:07 +07:00
Yoga Pangestu
5994f38f01 feat: implement permission checks for academic terms, courses, departments, and user management
- Added permission checks for creating, updating, and deleting academic terms, courses, and departments.
- Updated the routes to enforce permissions for various actions in the admin panel.
- Enhanced user management by adding permissions for administrators, lecturers, and students.
- Refactored components to conditionally render actions based on user permissions.
- Updated the auth type to include optional permissions array for user roles.
2026-08-30 23:27:31 +07:00
55 changed files with 1643 additions and 252 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Admin\Developer;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Services\Admin\Developer\LogViewerService;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class LogController extends Controller
{
public function __construct(
private readonly LogViewerService $service,
) {}
public function index(PaginatedRequest $request): Response
{
$files = $this->service->availableFiles();
$file = $request->validated('file') ?: $files->first()['name'] ?? null;
return Inertia::render('admin/developer/logs/index', [
'entries' => $file
? $this->service->paginated(
$file,
...$request->validatedWithDefaults(),
level: $request->validated('level'),
)
: null,
'files' => $files,
'selectedFile' => $file,
'filters' => $request->only(['file', 'level']),
]);
}
public function download(PaginatedRequest $request): BinaryFileResponse
{
$file = $request->validated('file');
abort_unless($file, 404);
$files = $this->service->availableFiles();
abort_unless($files->pluck('name')->contains($file), 404);
return response()->download(storage_path('logs/'.$file));
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Admin\Developer;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Developer\UpdateRolePermissionsRequest;
use App\Services\Admin\Developer\RolePermissionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class RolePermissionController extends Controller
{
public function __construct(
private readonly RolePermissionService $service,
) {}
public function index(): Response
{
return Inertia::render('admin/developer/roles/index', [
'roles' => $this->service->roles(),
'permissionGroups' => $this->service->groupedPermissions(),
]);
}
public function update(UpdateRolePermissionsRequest $request, Role $role): RedirectResponse
{
$this->service->updatePermissions($role, $request->validated('permissions'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Permission role berhasil diperbarui.']);
return to_route('admin.developer.roles.index');
}
}

View File

@ -42,6 +42,7 @@ public function share(Request $request): array
'name' => config('app.name'),
'auth' => [
'user' => $request->user()?->load('roles:id,name'),
'permissions' => $request->user()?->getAllPermissions()->pluck('name')->values() ?? [],
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'unreadNotificationsCount' => fn () => $request->user()

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Admin\Developer;
use App\Support\PermissionCatalog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRolePermissionsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'permissions' => ['present', 'array'],
'permissions.*' => ['string', Rule::in(PermissionCatalog::all())],
];
}
}

View File

@ -35,6 +35,8 @@ public function rules(): array
'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'],
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
'file' => ['nullable', 'string'],
'level' => ['nullable', 'string'],
];
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Services\Admin\Developer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
class LogViewerService
{
private const ENTRY_PATTERN = '/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2})?)\]\s+(\w+)\.(\w+):\s?(.*)$/s';
/**
* @return Collection<int, array{name: string, size: int, modified_at: string}>
*/
public function availableFiles(): Collection
{
return collect(File::glob(storage_path('logs/laravel*.log')))
->map(fn (string $path) => [
'name' => basename($path),
'size' => File::size($path),
'modified_at' => date('Y-m-d H:i:s', File::lastModified($path)),
'modified_timestamp' => File::lastModified($path),
])
->sortByDesc('modified_timestamp')
->values()
->map(fn (array $file) => collect($file)->except('modified_timestamp')->all());
}
public function latestFileName(): ?string
{
return $this->availableFiles()->first()['name'] ?? null;
}
public function paginated(string $fileName, int $perPage = 25, string $search = '', ?string $level = null): LengthAwarePaginator
{
$entries = $this->parse($fileName)
->when($level, fn (Collection $q) => $q->where('level', strtoupper($level)))
->when($search, fn (Collection $q) => $q->filter(
fn (array $entry) => str_contains(strtolower($entry['message']), strtolower($search))
))
->values();
$page = request()->integer('page', 1);
$items = $entries->forPage($page, $perPage)->values();
return new Paginator(
$items,
$entries->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()],
);
}
/**
* @return Collection<int, array{id: string, timestamp: string, environment: string, level: string, message: string, raw: string}>
*/
private function parse(string $fileName): Collection
{
$path = $this->resolvePath($fileName);
if (! $path) {
return collect();
}
$lines = preg_split('/\R/', File::get($path)) ?: [];
$entries = [];
$current = null;
foreach ($lines as $line) {
if (preg_match(self::ENTRY_PATTERN, $line, $matches)) {
if ($current) {
$entries[] = $current;
}
$current = [
'timestamp' => $matches[1],
'environment' => $matches[2],
'level' => strtoupper($matches[3]),
'message' => $matches[4],
'raw' => $line,
];
} elseif ($current !== null && trim($line) !== '') {
$current['message'] .= "\n".$line;
$current['raw'] .= "\n".$line;
}
}
if ($current) {
$entries[] = $current;
}
return collect($entries)
->reverse()
->values()
->map(fn (array $entry, int $index) => [...$entry, 'id' => (string) $index]);
}
private function resolvePath(string $fileName): ?string
{
$allowed = $this->availableFiles()->pluck('name');
if (! $allowed->contains($fileName)) {
return null;
}
return storage_path('logs/'.$fileName);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Services\Admin\Developer;
use App\Support\PermissionCatalog;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class RolePermissionService
{
/**
* @return array<string, array<int, string>>
*/
public function groupedPermissions(): array
{
return PermissionCatalog::groups();
}
/**
* @return Collection<int, array{id: int, name: string, permissions: array<int, string>}>
*/
public function roles(): Collection
{
return Role::with('permissions:id,name')
->orderBy('name')
->get(['id', 'name'])
->map(fn (Role $role) => [
'id' => $role->id,
'name' => $role->name,
'permissions' => $role->permissions->pluck('name')->all(),
]);
}
/**
* @param array<int, string> $permissionNames
*/
public function updatePermissions(Role $role, array $permissionNames): Role
{
$valid = array_values(array_intersect($permissionNames, PermissionCatalog::all()));
if ($role->name === 'developer') {
$valid = array_values(array_unique([...$valid, 'view-roles', 'update-roles']));
}
$role->syncPermissions($valid);
app(PermissionRegistrar::class)->forgetCachedPermissions();
return $role->load('permissions:id,name');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Support;
class PermissionCatalog
{
public const DASHBOARD = ['view-dashboard'];
public const MASTER = [
'view-academic-terms', 'create-academic-terms', 'update-academic-terms', 'delete-academic-terms', 'update-academic-terms-status',
'view-departments', 'create-departments', 'update-departments', 'delete-departments',
'view-courses', 'create-courses', 'update-courses', 'delete-courses',
];
public const ACADEMIC_CLASSES = [
'view-materials', 'create-materials', 'update-materials', 'delete-materials',
'view-assignments', 'create-assignments', 'update-assignments', 'delete-assignments',
'view-assignment-submissions', 'create-assignment-submissions', 'update-assignment-submissions', 'delete-assignment-submissions',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
'view-attendances', 'create-attendances', 'delete-attendances',
];
public const MANAGE = [
'view-course-classes', 'create-course-classes', 'update-course-classes', 'delete-course-classes',
'view-course-class-enrollments', 'create-course-class-enrollments', 'delete-course-class-enrollments',
'view-course-registrations', 'create-course-registrations', 'approve-course-registrations', 'reject-course-registrations',
'view-announcements', 'create-announcements', 'update-announcements', 'delete-announcements',
];
public const FINANCES = [
'view-tuition-invoices', 'create-tuition-invoices', 'update-tuition-invoices', 'delete-tuition-invoices',
'view-tuition-payments', 'create-tuition-payments', 'update-tuition-payments', 'delete-tuition-payments',
];
public const SERVICES = [
'view-letter-requests', 'create-letter-requests', 'update-letter-requests', 'delete-letter-requests',
'view-academic-advising-logs', 'create-academic-advising-logs', 'update-academic-advising-logs', 'delete-academic-advising-logs',
];
public const USERS = [
'view-lecturers', 'create-lecturers', 'update-lecturers', 'delete-lecturers', 'reset-lecturers-password', 'update-lecturers-status', 'export-lecturers',
'view-students', 'create-students', 'update-students', 'delete-students', 'reset-students-password', 'update-students-academic-status', 'update-students-account-status', 'export-students',
'view-administrators', 'create-administrators', 'update-administrators', 'delete-administrators', 'reset-administrators-password', 'update-administrators-status',
];
public const DEVELOPER = [
'view-logs',
'view-roles', 'update-roles',
];
/**
* @return array<string, array<int, string>>
*/
public static function groups(): array
{
return [
'Dashboard' => self::DASHBOARD,
'Master' => self::MASTER,
'Kelas' => self::ACADEMIC_CLASSES,
'Kelola' => self::MANAGE,
'Keuangan' => self::FINANCES,
'Layanan' => self::SERVICES,
'Pengguna' => self::USERS,
'Pengembang' => self::DEVELOPER,
];
}
/**
* @return array<int, string>
*/
public static function all(): array
{
return array_merge(...array_values(self::groups()));
}
}

View File

@ -7,6 +7,7 @@
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
use Illuminate\Http\Request;
use Spatie\Permission\Middleware\PermissionMiddleware;
use Spatie\Permission\Middleware\RoleMiddleware;
return Application::configure(basePath: dirname(__DIR__))
@ -26,6 +27,7 @@
$middleware->alias([
'role' => RoleMiddleware::class,
'permission' => PermissionMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {

View File

@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Support\PermissionCatalog;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
@ -13,17 +14,17 @@ public function run(): void
{
app()[PermissionRegistrar::class]->forgetCachedPermissions();
$permissionNames = [
'view-dashboard',
'view-academic-terms',
'create-academic-terms',
'update-academic-terms',
'delete-academic-terms',
];
$master = PermissionCatalog::MASTER;
$academicClasses = PermissionCatalog::ACADEMIC_CLASSES;
$manage = PermissionCatalog::MANAGE;
$finances = PermissionCatalog::FINANCES;
$services = PermissionCatalog::SERVICES;
$users = PermissionCatalog::USERS;
$permissionNames = PermissionCatalog::all();
$permissions = [];
foreach ($permissionNames as $name) {
$permissions[] = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
}
app()[PermissionRegistrar::class]->forgetCachedPermissions();
@ -33,19 +34,30 @@ public function run(): void
'dosen' => ['view-dashboard'],
'staff-admin' => [
'view-dashboard',
'view-academic-terms',
'create-academic-terms',
'update-academic-terms',
'delete-academic-terms',
...$master,
...$academicClasses,
...$manage,
...$services,
...$users,
],
'staff-keuangan' => [
'view-dashboard',
...$finances,
],
'kaprodi' => [
'view-dashboard',
'view-academic-terms',
'view-courses',
'view-course-classes',
'view-course-registrations',
'view-students',
],
'staff-keuangan' => ['view-dashboard'],
'kaprodi' => ['view-dashboard', 'view-academic-terms'],
'developer' => $permissionNames,
];
foreach ($roles as $roleName => $rolePermissions) {
$role = Role::firstOrCreate(['name' => $roleName, 'guard_name' => 'web']);
$role->givePermissionTo($rolePermissions);
$role->syncPermissions($rolePermissions);
}
}
}

View File

@ -3,15 +3,21 @@ import { Switch } from '@/components/ui/switch';
type ActiveStatusSwitchProps = {
isActive: boolean;
onChange: (isActive: boolean) => void;
disabled?: boolean;
};
export function ActiveStatusSwitch({
isActive,
onChange,
disabled,
}: ActiveStatusSwitchProps) {
return (
<div className="flex items-center gap-2">
<Switch checked={isActive} onCheckedChange={onChange} />
<Switch
checked={isActive}
onCheckedChange={onChange}
disabled={disabled}
/>
<span className="text-sm text-muted-foreground">
{isActive ? 'Aktif' : 'Nonaktif'}
</span>

View File

@ -16,6 +16,8 @@ import {
MessageCircle,
Receipt,
School,
ScrollText,
ShieldCheck,
User,
Users,
} from 'lucide-react';
@ -37,6 +39,8 @@ import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assig
import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances';
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
import { index as logsRoute } from '@/routes/admin/developer/logs';
import { index as rolePermissionsRoute } from '@/routes/admin/developer/roles';
import { index as feedbackRoute } from '@/routes/admin/feedback';
import { index as tuitionInvoicesRoute } from '@/routes/admin/finances/tuition-invoices';
import { index as announcementsRoute } from '@/routes/admin/manage/announcements';
@ -60,9 +64,13 @@ const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
function buildNavMain({
isMahasiswa,
isDosen,
canViewLogs,
canViewRoles,
}: {
isMahasiswa: boolean;
isDosen: boolean;
canViewLogs: boolean;
canViewRoles: boolean;
}): (NavGroup | NavItem)[] {
return [
{
@ -186,6 +194,33 @@ function buildNavMain({
},
],
},
...(canViewLogs || canViewRoles
? [
{
label: 'Pengembang',
items: [
...(canViewLogs
? [
{
name: 'Logs',
url: logsRoute.url(),
icon: ScrollText,
},
]
: []),
...(canViewRoles
? [
{
name: 'Role & Permission',
url: rolePermissionsRoute.url(),
icon: ShieldCheck,
},
]
: []),
],
},
]
: []),
];
}
@ -208,7 +243,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
const isDosen = !isStaff && roleNames.includes('dosen');
const navMain = buildNavMain({ isMahasiswa, isDosen });
const canViewLogs = (auth?.permissions ?? []).includes('view-logs');
const canViewRoles = (auth?.permissions ?? []).includes('view-roles');
const navMain = buildNavMain({
isMahasiswa,
isDosen,
canViewLogs,
canViewRoles,
});
return (
<Sidebar collapsible="icon" {...props}>

View File

@ -13,6 +13,7 @@ type StudentStatusBadgeProps = {
status: string | null | undefined;
statuses: StatusOption[];
onChange: (status: string) => void;
disabled?: boolean;
};
const StudentStatusVariants: Record<
@ -29,6 +30,7 @@ export function StudentStatusBadge({
status,
statuses,
onChange,
disabled,
}: StudentStatusBadgeProps) {
const label =
statuses.find((option) => option.value === status)?.label ??
@ -38,10 +40,11 @@ export function StudentStatusBadge({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild disabled={disabled}>
<button
type="button"
className="cursor-pointer rounded-full border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={disabled}
className="cursor-pointer rounded-full border-0 bg-transparent p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-70"
>
<Badge variant={variant}>{label}</Badge>
</button>

View File

@ -0,0 +1,17 @@
import { usePage } from '@inertiajs/react';
import type { Auth } from '@/types/auth';
export function usePermissions() {
const { auth } = usePage<{ auth: Auth }>().props;
const permissions = auth?.permissions ?? [];
function hasPermission(name: string): boolean {
return permissions.includes(name);
}
function hasAnyPermission(names: string[]): boolean {
return names.some((name) => permissions.includes(name));
}
return { permissions, hasPermission, hasAnyPermission };
}

View File

@ -10,12 +10,21 @@ export type { Assignment } from '@/types/assignment';
type CreateColumnsParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
canUpdate: boolean;
canDelete: boolean;
canViewSubmissions: boolean;
};
export function createAssignmentColumns(
params: CreateColumnsParams,
): ColumnDef<Assignment>[] {
const { handleEdit, handleDeleteClick } = params;
const {
handleEdit,
handleDeleteClick,
canUpdate,
canDelete,
canViewSubmissions,
} = params;
return [
{
@ -103,11 +112,13 @@ export function createAssignmentColumns(
{
label: 'Pengumpulan',
icon: <ClipboardList className="h-4 w-4" />,
show: canViewSubmissions,
href: submissionsIndex.url(assignment.id),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(assignment),
},
{
@ -115,6 +126,7 @@ export function createAssignmentColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(assignment),
},
]}

View File

@ -22,6 +22,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as assignmentIndex,
@ -65,6 +66,11 @@ export default function AssignmentIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-assignments');
const canUpdate = hasPermission('update-assignments');
const canDelete = hasPermission('delete-assignments');
const canViewSubmissions = hasPermission('view-assignment-submissions');
const filterFields: FilterField[] = [
{
@ -109,6 +115,9 @@ export default function AssignmentIndex({
const columns = createAssignmentColumns({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
canUpdate,
canDelete,
canViewSubmissions,
});
return (
@ -141,15 +150,17 @@ export default function AssignmentIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -24,6 +24,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { index as assignmentIndex } from '@/routes/admin/academic-classes/assignments';
import {
destroy,
@ -48,6 +49,10 @@ export default function SubmissionIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Submission | null>(null);
const [deleting, setDeleting] = useState<Submission | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-assignment-submissions');
const canUpdate = hasPermission('update-assignment-submissions');
const canDelete = hasPermission('delete-assignment-submissions');
function handleDelete() {
if (!deleting) {
@ -137,6 +142,7 @@ export default function SubmissionIndex({
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => setEditing(row.original),
},
{
@ -144,6 +150,7 @@ export default function SubmissionIndex({
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => setDeleting(row.original),
},
]}
@ -193,13 +200,15 @@ export default function SubmissionIndex({
<h2 className="text-lg font-semibold">
Daftar Pengumpulan
</h2>
<Button
onClick={() => setCreateOpen(true)}
disabled={availableStudents.length === 0}
>
<Plus className="h-4 w-4" />
Tambah Pengumpulan
</Button>
{canCreate && (
<Button
onClick={() => setCreateOpen(true)}
disabled={availableStudents.length === 0}
>
<Plus className="h-4 w-4" />
Tambah Pengumpulan
</Button>
)}
</div>
<DataTable columns={columns} data={submissions} />

View File

@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { destroy, session } from '@/routes/admin/academic-classes/attendances';
import type {
AttendanceCourseClass,
@ -41,6 +42,9 @@ function courseClassLabel(courseClass: AttendanceCourseClass): string {
export default function AttendanceIndex({ sessions, courseClasses }: Props) {
const [newSessionOpen, setNewSessionOpen] = useState(false);
const [deleting, setDeleting] = useState<AttendanceSession | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-attendances');
const canDelete = hasPermission('delete-attendances');
function handleDelete() {
if (!deleting || deleting.meeting_number === null) {
@ -61,10 +65,12 @@ export default function AttendanceIndex({ sessions, courseClasses }: Props) {
<PageHeader
title="Kehadiran"
actions={
<Button onClick={() => setNewSessionOpen(true)}>
<Plus className="h-4 w-4" />
Ambil Kehadiran
</Button>
canCreate && (
<Button onClick={() => setNewSessionOpen(true)}>
<Plus className="h-4 w-4" />
Ambil Kehadiran
</Button>
)
}
/>
@ -129,16 +135,18 @@ export default function AttendanceIndex({ sessions, courseClasses }: Props) {
</span>
)}
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canOpen}
onClick={() =>
setDeleting(item)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
{canDelete && (
<Button
variant="ghost"
size="sm"
disabled={!canOpen}
onClick={() =>
setDeleting(item)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
</CardContent>
</Card>

View File

@ -7,6 +7,7 @@ import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { usePermissions } from '@/hooks/use-permissions';
import {
index as attendanceIndex,
store,
@ -45,6 +46,8 @@ export default function AttendanceSession({
),
);
const [saving, setSaving] = useState(false);
const { hasPermission } = usePermissions();
const canSave = hasPermission('create-attendances');
function setAll(status: AttendanceStatus) {
setStatuses(
@ -198,7 +201,7 @@ export default function AttendanceSession({
</div>
)}
{roster.length > 0 && (
{roster.length > 0 && canSave && (
<div className="flex justify-end">
<Button
onClick={handleSave}

View File

@ -9,12 +9,14 @@ export type { Material } from '@/types/material';
type CreateColumnsParams = {
handleEdit: (material: Material) => void;
handleDeleteClick: (material: Material) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createMaterialColumns(
params: CreateColumnsParams,
): ColumnDef<Material>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -100,6 +102,7 @@ export function createMaterialColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(material),
},
{
@ -107,6 +110,7 @@ export function createMaterialColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(material),
},
]}

View File

@ -21,6 +21,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as materialIndex,
@ -64,6 +65,10 @@ export default function MaterialIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Material | null>(null);
const [deleting, setDeleting] = useState<Material | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-materials');
const canUpdate = hasPermission('update-materials');
const canDelete = hasPermission('delete-materials');
const filterFields: FilterField[] = [
{
@ -108,6 +113,8 @@ export default function MaterialIndex({
const columns = createMaterialColumns({
handleEdit: (material) => setEditing(material),
handleDeleteClick: (material) => setDeleting(material),
canUpdate,
canDelete,
});
return (
@ -140,15 +147,17 @@ export default function MaterialIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { cn } from '@/lib/utils';
import {
destroy,
@ -58,6 +59,10 @@ export default function ScheduleIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Schedule | null>(null);
const [deleting, setDeleting] = useState<Schedule | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-schedules');
const canUpdate = hasPermission('update-schedules');
const canDelete = hasPermission('delete-schedules');
function handleDelete() {
if (!deleting) {
@ -89,15 +94,17 @@ export default function ScheduleIndex({
<PageHeader
title="Jadwal"
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>
@ -177,6 +184,7 @@ export default function ScheduleIndex({
icon: (
<Pencil className="h-3.5 w-3.5" />
),
show: canUpdate,
onClick:
() =>
setEditing(
@ -188,6 +196,7 @@ export default function ScheduleIndex({
icon: (
<Trash2 className="h-3.5 w-3.5 text-destructive" />
),
show: canDelete,
onClick:
() =>
setDeleting(

View File

@ -0,0 +1,106 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { Eye } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { LogEntry, LogLevel } from '@/types/log-entry';
function levelVariant(
level: LogLevel,
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (['EMERGENCY', 'ALERT', 'CRITICAL', 'ERROR'].includes(level)) {
return 'destructive';
}
if (['WARNING', 'NOTICE'].includes(level)) {
return 'secondary';
}
if (level === 'INFO') {
return 'default';
}
return 'outline';
}
function firstLine(message: string): string {
return message.split('\n')[0];
}
type CreateColumnsParams = {
handleViewDetail: (entry: LogEntry) => void;
};
export function createLogColumns(
params: CreateColumnsParams,
): ColumnDef<LogEntry>[] {
const { handleViewDetail } = params;
return [
{
accessorKey: 'timestamp',
header: () => <span>Waktu</span>,
meta: {
className: 'w-[180px]',
headerClassName: 'w-[180px]',
},
cell: ({ row }) => {
const timestamp = row.getValue('timestamp') as string;
const date = new Date(timestamp.replace(' ', 'T'));
return (
<span className="whitespace-nowrap">
{Number.isNaN(date.getTime())
? timestamp
: format(date, 'd MMM yyyy, HH:mm:ss')}
</span>
);
},
},
{
accessorKey: 'level',
header: () => <span className="block text-center">Level</span>,
meta: {
className: 'w-[110px] text-center',
headerClassName: 'w-[110px] text-center',
},
cell: ({ row }) => {
const level = row.getValue('level') as LogLevel;
return (
<div className="flex justify-center">
<Badge variant={levelVariant(level)}>{level}</Badge>
</div>
);
},
},
{
accessorKey: 'message',
header: () => <span>Pesan</span>,
cell: ({ row }) => (
<p className="line-clamp-2 max-w-2xl font-mono text-xs break-all">
{firstLine(row.original.message)}
</p>
),
},
{
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[80px] text-center',
headerClassName: 'w-[80px] text-center',
},
cell: ({ row }) => (
<RowActions
actions={[
{
label: 'Lihat Detail',
icon: <Eye className="h-4 w-4" />,
onClick: () => handleViewDetail(row.original),
},
]}
/>
),
},
];
}

View File

@ -0,0 +1,219 @@
import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Download } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import { index as logsIndex, download } from '@/routes/admin/developer/logs';
import type { LogEntry, LogFile } from '@/types/log-entry';
import { LogLevels } from '@/types/log-entry';
import { createLogColumns } from './columns';
type Props = {
entries: {
data: LogEntry[];
current_page: number;
last_page: number;
per_page: number;
total: number;
} | null;
files: LogFile[];
selectedFile: string | null;
filters: {
file?: string;
level?: string;
};
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function LogIndex({
entries,
files,
selectedFile,
filters,
}: Props) {
const [detail, setDetail] = useState<LogEntry | null>(null);
const filterFields: FilterField[] = [
{
key: 'level',
label: 'Level',
options: LogLevels.map((level) => ({
value: level,
label: level,
})),
},
];
const pagination: PaginationState = {
current_page: entries?.current_page ?? 1,
last_page: entries?.last_page ?? 1,
per_page: entries?.per_page ?? 25,
total: entries?.total ?? 0,
};
const {
search,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilters,
} = useServerTable({
route: () => logsIndex.url(),
pagination,
filters,
});
function handleFileChange(file: string) {
router.get(
logsIndex.url(),
{ file, level: filters.level },
{ preserveState: true, replace: true },
);
}
const columns = createLogColumns({
handleViewDetail: (entry) => setDetail(entry),
});
return (
<>
<Head title="Logs" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Logs"
actions={
selectedFile && (
<Button variant="outline" asChild>
<a
href={download.url({
query: { file: selectedFile },
})}
>
<Download className="h-4 w-4" />
Unduh
</a>
</Button>
)
}
/>
<div className="flex flex-wrap items-center gap-3">
<Select
value={selectedFile ?? undefined}
onValueChange={handleFileChange}
>
<SelectTrigger className="w-full sm:w-80">
<SelectValue placeholder="Pilih file log" />
</SelectTrigger>
<SelectContent>
{files.map((file) => (
<SelectItem key={file.name} value={file.name}>
{file.name} &middot;{' '}
{formatFileSize(file.size)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{entries ? (
<DataTable
columns={columns}
data={entries.data}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
onSearchChange={handleSearchChange}
searchValue={search}
searchKey="message"
searchPlaceholder="Cari pesan log..."
emptyText="Tidak ada entri log yang cocok."
toolbar={
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
}
/>
) : (
<p className="text-sm text-muted-foreground">
Belum ada file log yang tersedia.
</p>
)}
<Dialog
open={detail !== null}
onOpenChange={(open) => {
if (!open) {
setDetail(null);
}
}}
>
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{detail?.level && (
<Badge variant="outline">
{detail.level}
</Badge>
)}
<span>Detail Log</span>
</DialogTitle>
<DialogDescription>
{detail &&
!Number.isNaN(
new Date(
detail.timestamp.replace(' ', 'T'),
).getTime(),
) &&
format(
new Date(
detail.timestamp.replace(' ', 'T'),
),
'd MMM yyyy, HH:mm:ss',
)}
</DialogDescription>
</DialogHeader>
<pre className="overflow-y-auto rounded-md bg-muted p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.raw}
</pre>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@ -0,0 +1,186 @@
import { Head, router } from '@inertiajs/react';
import { Save } from 'lucide-react';
import { useState } from 'react';
import { PageHeader } from '@/components/page-header';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { update } from '@/routes/admin/developer/roles';
import type {
PermissionGroups,
RoleWithPermissions,
} from '@/types/role-permission';
import { formatRoleLabel } from '@/types/role-permission';
type Props = {
roles: RoleWithPermissions[];
permissionGroups: PermissionGroups;
};
export default function RolePermissionIndex({
roles,
permissionGroups,
}: Props) {
const [selectedRoleId, setSelectedRoleId] = useState<number | undefined>(
roles[0]?.id,
);
const selectedRole = roles.find((role) => role.id === selectedRoleId);
const [selected, setSelected] = useState<string[]>(
selectedRole?.permissions ?? [],
);
const [processing, setProcessing] = useState(false);
function handleSelectRole(id: string) {
const role = roles.find((r) => r.id === Number(id));
setSelectedRoleId(role?.id);
setSelected(role?.permissions ?? []);
}
function toggle(name: string, checked: boolean) {
setSelected((prev) =>
checked ? [...prev, name] : prev.filter((p) => p !== name),
);
}
function toggleGroup(names: string[], checked: boolean) {
setSelected((prev) => {
const withoutGroup = prev.filter((p) => !names.includes(p));
return checked ? [...withoutGroup, ...names] : withoutGroup;
});
}
function handleSave() {
if (!selectedRole) {
return;
}
setProcessing(true);
router.put(
update.url(selectedRole.id),
{ permissions: selected },
{
preserveScroll: true,
onFinish: () => setProcessing(false),
},
);
}
return (
<>
<Head title="Role & Permission" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Role & Permission"
actions={
selectedRole && (
<Button onClick={handleSave} disabled={processing}>
<Save className="h-4 w-4" />
Simpan
</Button>
)
}
/>
<div className="flex items-center gap-3">
<Select
value={
selectedRoleId ? String(selectedRoleId) : undefined
}
onValueChange={handleSelectRole}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Pilih role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.id}
value={String(role.id)}
>
{formatRoleLabel(role.name)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedRole && (
<Badge variant="secondary">
{selected.length} permission
</Badge>
)}
</div>
{selectedRole && (
<div className="grid gap-4">
{Object.entries(permissionGroups).map(
([group, names]) => {
const allChecked = names.every((name) =>
selected.includes(name),
);
return (
<Card key={group}>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<CardTitle className="text-base">
{group}
</CardTitle>
<label className="flex items-center gap-2 text-sm font-normal text-muted-foreground">
<Checkbox
checked={allChecked}
onCheckedChange={(
checked,
) =>
toggleGroup(
names,
checked === true,
)
}
/>
Pilih Semua
</label>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{names.map((name) => (
<label
key={name}
className="flex items-center gap-2 rounded-md border p-2 text-sm hover:bg-muted"
>
<Checkbox
checked={selected.includes(
name,
)}
onCheckedChange={(
checked,
) =>
toggle(
name,
checked ===
true,
)
}
/>
<span className="font-mono text-xs">
{name}
</span>
</label>
))}
</CardContent>
</Card>
);
},
)}
</div>
)}
</div>
</>
);
}

View File

@ -14,12 +14,21 @@ export type { TuitionInvoice } from '@/types/tuition-invoice';
type CreateColumnsParams = {
handleEdit: (invoice: TuitionInvoice) => void;
handleDeleteClick: (invoice: TuitionInvoice) => void;
canUpdate: boolean;
canDelete: boolean;
canViewPayments: boolean;
};
export function createTuitionInvoiceColumns(
params: CreateColumnsParams,
): ColumnDef<TuitionInvoice>[] {
const { handleEdit, handleDeleteClick } = params;
const {
handleEdit,
handleDeleteClick,
canUpdate,
canDelete,
canViewPayments,
} = params;
return [
{
@ -133,11 +142,13 @@ export function createTuitionInvoiceColumns(
{
label: 'Pembayaran',
icon: <CreditCard className="h-4 w-4" />,
show: canViewPayments,
href: paymentsIndex.url(invoice.id),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(invoice),
},
{
@ -145,6 +156,7 @@ export function createTuitionInvoiceColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(invoice),
},
]}

View File

@ -47,6 +47,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import { formatRupiah } from '@/lib/currency';
import {
@ -115,6 +116,11 @@ export default function TuitionInvoiceIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<TuitionInvoice | null>(null);
const [deleting, setDeleting] = useState<TuitionInvoice | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-tuition-invoices');
const canUpdate = hasPermission('update-tuition-invoices');
const canDelete = hasPermission('delete-tuition-invoices');
const canViewPayments = hasPermission('view-tuition-payments');
const filterFields: FilterField[] = [
{
@ -175,6 +181,9 @@ export default function TuitionInvoiceIndex({
const columns = createTuitionInvoiceColumns({
handleEdit: (invoice) => setEditing(invoice),
handleDeleteClick: (invoice) => setDeleting(invoice),
canUpdate,
canDelete,
canViewPayments,
});
return (
@ -192,15 +201,17 @@ export default function TuitionInvoiceIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { formatRupiah } from '@/lib/currency';
import { index as tuitionInvoiceIndex } from '@/routes/admin/finances/tuition-invoices';
import {
@ -43,6 +44,10 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<TuitionPayment | null>(null);
const [deleting, setDeleting] = useState<TuitionPayment | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-tuition-payments');
const canUpdate = hasPermission('update-tuition-payments');
const canDelete = hasPermission('delete-tuition-payments');
function handleDelete() {
if (!deleting) {
@ -156,6 +161,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => setEditing(row.original),
},
{
@ -163,6 +169,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => setDeleting(row.original),
},
]}
@ -231,7 +238,7 @@ export default function TuitionPaymentIndex({ invoice, payments }: Props) {
<h2 className="text-lg font-semibold">
Riwayat Pembayaran
</h2>
{!isFullyPaid && (
{!isFullyPaid && canCreate && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Tambah Pembayaran

View File

@ -10,12 +10,14 @@ export type { Announcement } from '@/types/announcement';
type CreateColumnsParams = {
handleEdit: (announcement: Announcement) => void;
handleDeleteClick: (announcement: Announcement) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createAnnouncementColumns(
params: CreateColumnsParams,
): ColumnDef<Announcement>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -82,6 +84,7 @@ export function createAnnouncementColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
@ -89,6 +92,7 @@ export function createAnnouncementColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}

View File

@ -20,6 +20,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as announcementIndex,
@ -58,6 +59,10 @@ export default function AnnouncementIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Announcement | null>(null);
const [deleting, setDeleting] = useState<Announcement | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-announcements');
const canUpdate = hasPermission('update-announcements');
const canDelete = hasPermission('delete-announcements');
const filterFields: FilterField[] = [
{
@ -102,6 +107,8 @@ export default function AnnouncementIndex({
const columns = createAnnouncementColumns({
handleEdit: (announcement) => setEditing(announcement),
handleDeleteClick: (announcement) => setDeleting(announcement),
canUpdate,
canDelete,
});
return (
@ -119,15 +126,17 @@ export default function AnnouncementIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -12,12 +12,21 @@ export type { CourseClass } from '@/types/course-class';
type CreateColumnsParams = {
handleEdit: (courseClass: CourseClass) => void;
handleDeleteClick: (courseClass: CourseClass) => void;
canUpdate: boolean;
canDelete: boolean;
canViewEnrollments: boolean;
};
export function createCourseClassColumns(
params: CreateColumnsParams,
): ColumnDef<CourseClass>[] {
const { handleEdit, handleDeleteClick } = params;
const {
handleEdit,
handleDeleteClick,
canUpdate,
canDelete,
canViewEnrollments,
} = params;
return [
{
@ -97,11 +106,13 @@ export function createCourseClassColumns(
{
label: 'Peserta',
icon: <Users className="h-4 w-4" />,
show: canViewEnrollments,
href: enrollmentsIndex.url(courseClass.id),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(courseClass),
},
{
@ -109,6 +120,7 @@ export function createCourseClassColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(courseClass),
},
]}

View File

@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { usePermissions } from '@/hooks/use-permissions';
import { index as courseClassIndex } from '@/routes/admin/manage/course-classes';
import {
destroy,
@ -39,6 +40,9 @@ export default function ClassEnrollmentIndex({
}: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [deleting, setDeleting] = useState<ClassEnrollment | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-course-class-enrollments');
const canDelete = hasPermission('delete-course-class-enrollments');
function handleDelete() {
if (!deleting) {
@ -93,6 +97,7 @@ export default function ClassEnrollmentIndex({
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => setDeleting(row.original),
},
]}
@ -152,10 +157,12 @@ export default function ClassEnrollmentIndex({
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Daftar Mahasiswa</h2>
<Button onClick={() => setCreateOpen(true)}>
<UserPlus className="h-4 w-4" />
Tambah Mahasiswa
</Button>
{canCreate && (
<Button onClick={() => setCreateOpen(true)}>
<UserPlus className="h-4 w-4" />
Tambah Mahasiswa
</Button>
)}
</div>
<DataTable columns={columns} data={enrollments} />

View File

@ -26,6 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseClassIndex,
@ -76,6 +77,11 @@ export default function CourseClassIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<CourseClass | null>(null);
const [deleting, setDeleting] = useState<CourseClass | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-course-classes');
const canUpdate = hasPermission('update-course-classes');
const canDelete = hasPermission('delete-course-classes');
const canViewEnrollments = hasPermission('view-course-class-enrollments');
const filterFields: FilterField[] = [
{
@ -128,6 +134,9 @@ export default function CourseClassIndex({
const columns = createCourseClassColumns({
handleEdit: (courseClass) => setEditing(courseClass),
handleDeleteClick: (courseClass) => setDeleting(courseClass),
canUpdate,
canDelete,
canViewEnrollments,
});
return (
@ -160,15 +169,17 @@ export default function CourseClassIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -11,12 +11,14 @@ export type { CourseRegistrationSubmission } from '@/types/course-registration';
type CreateColumnsParams = {
handleApprove: (submission: CourseRegistrationSubmission) => void;
handleRejectClick: (submission: CourseRegistrationSubmission) => void;
canApprove: boolean;
canReject: boolean;
};
export function createCourseRegistrationColumns(
params: CreateColumnsParams,
): ColumnDef<CourseRegistrationSubmission>[] {
const { handleApprove, handleRejectClick } = params;
const { handleApprove, handleRejectClick, canApprove, canReject } = params;
return [
{
@ -105,14 +107,18 @@ export function createCourseRegistrationColumns(
label: 'Setujui',
icon: <Check className="h-4 w-4" />,
iconClassName: 'text-primary',
show: submission.status === 'submitted',
show:
submission.status === 'submitted' &&
canApprove,
onClick: () => handleApprove(submission),
},
{
label: 'Tolak',
icon: <X className="h-4 w-4" />,
iconClassName: 'text-destructive',
show: submission.status === 'submitted',
show:
submission.status === 'submitted' &&
canReject,
onClick: () => handleRejectClick(submission),
},
]}

View File

@ -27,6 +27,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseRegistrationIndex,
@ -92,6 +93,10 @@ export default function CourseRegistrationIndex({
const [createOpen, setCreateOpen] = useState(false);
const [rejecting, setRejecting] =
useState<CourseRegistrationSubmission | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-course-registrations');
const canApprove = hasPermission('approve-course-registrations');
const canReject = hasPermission('reject-course-registrations');
const filterFields: FilterField[] = [
{
@ -138,6 +143,8 @@ export default function CourseRegistrationIndex({
const columns = createCourseRegistrationColumns({
handleApprove,
handleRejectClick: (submission) => setRejecting(submission),
canApprove,
canReject,
});
return (
@ -155,15 +162,17 @@ export default function CourseRegistrationIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -12,12 +12,22 @@ type CreateColumnsParams = {
handleEdit: (academicTerm: AcademicTerm) => void;
handleDeleteClick: (academicTerm: AcademicTerm) => void;
handleStatusChange: (academicTerm: AcademicTerm, isActive: boolean) => void;
canUpdate: boolean;
canDelete: boolean;
canUpdateStatus: boolean;
};
export function createAcademicTermColumns(
params: CreateColumnsParams,
): ColumnDef<AcademicTerm>[] {
const { handleEdit, handleDeleteClick, handleStatusChange } = params;
const {
handleEdit,
handleDeleteClick,
handleStatusChange,
canUpdate,
canDelete,
canUpdateStatus,
} = params;
return [
{
@ -73,6 +83,7 @@ export function createAcademicTermColumns(
onChange={(isActive) =>
handleStatusChange(academicTerm, isActive)
}
disabled={!canUpdateStatus}
/>
</div>
);
@ -94,6 +105,7 @@ export function createAcademicTermColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(academicTerm),
},
{
@ -101,6 +113,7 @@ export function createAcademicTermColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(academicTerm),
},
]}

View File

@ -15,6 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as academicTermIndex,
@ -69,6 +70,11 @@ export default function AcademicTermIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AcademicTerm | null>(null);
const [deleting, setDeleting] = useState<AcademicTerm | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-academic-terms');
const canUpdate = hasPermission('update-academic-terms');
const canDelete = hasPermission('delete-academic-terms');
const canUpdateStatus = hasPermission('update-academic-terms-status');
const pagination: PaginationState = {
current_page: academicTerms.current_page,
@ -111,6 +117,9 @@ export default function AcademicTermIndex({
handleEdit: (academicTerm) => setEditing(academicTerm),
handleDeleteClick: (academicTerm) => setDeleting(academicTerm),
handleStatusChange,
canUpdate,
canDelete,
canUpdateStatus,
});
return (
@ -143,15 +152,17 @@ export default function AcademicTermIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -9,12 +9,14 @@ export type { Course } from '@/types/course';
type CreateColumnsParams = {
handleEdit: (course: Course) => void;
handleDeleteClick: (course: Course) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createCourseColumns(
params: CreateColumnsParams,
): ColumnDef<Course>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -87,6 +89,7 @@ export function createCourseColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(course),
},
{
@ -94,6 +97,7 @@ export function createCourseColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(course),
},
]}

View File

@ -19,6 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as courseIndex,
@ -59,6 +60,10 @@ export default function CourseIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Course | null>(null);
const [deleting, setDeleting] = useState<Course | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-courses');
const canUpdate = hasPermission('update-courses');
const canDelete = hasPermission('delete-courses');
const filterFields: FilterField[] = [
{
@ -111,6 +116,8 @@ export default function CourseIndex({
const columns = createCourseColumns({
handleEdit: (course) => setEditing(course),
handleDeleteClick: (course) => setDeleting(course),
canUpdate,
canDelete,
});
return (
@ -143,15 +150,17 @@ export default function CourseIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -9,12 +9,14 @@ export type { Department } from '@/types/department';
type CreateColumnsParams = {
handleEdit: (department: Department) => void;
handleDeleteClick: (department: Department) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createDepartmentColumns(
params: CreateColumnsParams,
): ColumnDef<Department>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -69,6 +71,7 @@ export function createDepartmentColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(department),
},
{
@ -76,6 +79,7 @@ export function createDepartmentColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(department),
},
]}

View File

@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as departmentIndex,
@ -43,6 +44,10 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Department | null>(null);
const [deleting, setDeleting] = useState<Department | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-departments');
const canUpdate = hasPermission('update-departments');
const canDelete = hasPermission('delete-departments');
const pagination: PaginationState = {
current_page: departments.current_page,
@ -74,6 +79,8 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
const columns = createDepartmentColumns({
handleEdit: (department) => setEditing(department),
handleDeleteClick: (department) => setDeleting(department),
canUpdate,
canDelete,
});
return (
@ -106,15 +113,17 @@ export default function DepartmentIndex({ departments, highlight }: Props) {
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -9,12 +9,14 @@ export type { AcademicAdvisingLog } from '@/types/academic-advising-log';
type CreateColumnsParams = {
handleEdit: (log: AcademicAdvisingLog) => void;
handleDeleteClick: (log: AcademicAdvisingLog) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createAcademicAdvisingLogColumns(
params: CreateColumnsParams,
): ColumnDef<AcademicAdvisingLog>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -83,6 +85,7 @@ export function createAcademicAdvisingLogColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
@ -90,6 +93,7 @@ export function createAcademicAdvisingLogColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}

View File

@ -21,6 +21,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as academicAdvisingLogIndex,
@ -69,6 +70,10 @@ export default function AcademicAdvisingLogIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AcademicAdvisingLog | null>(null);
const [deleting, setDeleting] = useState<AcademicAdvisingLog | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-academic-advising-logs');
const canUpdate = hasPermission('update-academic-advising-logs');
const canDelete = hasPermission('delete-academic-advising-logs');
const filterFields: FilterField[] = [
{
@ -113,6 +118,8 @@ export default function AcademicAdvisingLogIndex({
const columns = createAcademicAdvisingLogColumns({
handleEdit: (log) => setEditing(log),
handleDeleteClick: (log) => setDeleting(log),
canUpdate,
canDelete,
});
return (
@ -130,15 +137,17 @@ export default function AcademicAdvisingLogIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -11,12 +11,14 @@ export type { LetterRequest } from '@/types/letter-request';
type CreateColumnsParams = {
handleEdit: (letterRequest: LetterRequest) => void;
handleDeleteClick: (letterRequest: LetterRequest) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createLetterRequestColumns(
params: CreateColumnsParams,
): ColumnDef<LetterRequest>[] {
const { handleEdit, handleDeleteClick } = params;
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return [
{
@ -143,6 +145,7 @@ export function createLetterRequestColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(row.original),
},
{
@ -150,6 +153,7 @@ export function createLetterRequestColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(row.original),
},
]}

View File

@ -21,6 +21,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as letterRequestIndex,
@ -63,6 +64,10 @@ export default function LetterRequestIndex({
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<LetterRequest | null>(null);
const [deleting, setDeleting] = useState<LetterRequest | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-letter-requests');
const canUpdate = hasPermission('update-letter-requests');
const canDelete = hasPermission('delete-letter-requests');
const filterFields: FilterField[] = [
{
@ -107,6 +112,8 @@ export default function LetterRequestIndex({
const columns = createLetterRequestColumns({
handleEdit: (letterRequest) => setEditing(letterRequest),
handleDeleteClick: (letterRequest) => setDeleting(letterRequest),
canUpdate,
canDelete,
});
return (
@ -124,15 +131,17 @@ export default function LetterRequestIndex({
)
}
actions={
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
canCreate && (
<Button asChild>
<button
type="button"
onClick={() => setCreateOpen(true)}
>
<Plus className="h-4 w-4" />
Tambah
</button>
</Button>
)
}
/>

View File

@ -18,6 +18,10 @@ type CreateColumnsParams = {
handleDeleteClick: (admin: Administrator) => void;
handleResetPassword: (admin: Administrator) => void;
handleUserStatusChange: (admin: Administrator, isActive: boolean) => void;
canUpdate: boolean;
canResetPassword: boolean;
canDelete: boolean;
canUpdateStatus: boolean;
};
export function createAdministratorColumns(
@ -28,6 +32,10 @@ export function createAdministratorColumns(
handleDeleteClick,
handleResetPassword,
handleUserStatusChange,
canUpdate,
canResetPassword,
canDelete,
canUpdateStatus,
} = params;
return [
@ -74,6 +82,7 @@ export function createAdministratorColumns(
onChange={(isActive) =>
handleUserStatusChange(row.original, isActive)
}
disabled={!canUpdateStatus}
/>
),
},
@ -93,11 +102,13 @@ export function createAdministratorColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(admin),
},
{
label: 'Reset Kata Sandi',
icon: <Key className="h-4 w-4" />,
show: canResetPassword,
onClick: () => handleResetPassword(admin),
},
{
@ -105,6 +116,7 @@ export function createAdministratorColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(admin),
},
]}

View File

@ -9,6 +9,7 @@ import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as administratorsIndex,
@ -48,6 +49,8 @@ const filterFields: FilterField[] = [
export default function AdministratorIndex({ administrators, filters }: Props) {
const [deleting, setDeleting] = useState<Administrator | null>(null);
const [resetting, setResetting] = useState<Administrator | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-administrators');
const pagination: PaginationState = {
current_page: administrators.current_page,
@ -107,6 +110,10 @@ export default function AdministratorIndex({ administrators, filters }: Props) {
handleDeleteClick: (admin) => setDeleting(admin),
handleResetPassword: (admin) => setResetting(admin),
handleUserStatusChange,
canUpdate: hasPermission('update-administrators'),
canResetPassword: hasPermission('reset-administrators-password'),
canDelete: hasPermission('delete-administrators'),
canUpdateStatus: hasPermission('update-administrators-status'),
});
return (
@ -117,12 +124,14 @@ export default function AdministratorIndex({ administrators, filters }: Props) {
<PageHeader
title="Administrator"
actions={
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
canCreate && (
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
)
}
/>

View File

@ -21,6 +21,10 @@ type CreateColumnsParams = {
handleDeleteClick: (lecturer: Lecturer) => void;
handleResetPassword: (lecturer: Lecturer) => void;
handleUserStatusChange: (lecturer: Lecturer, isActive: boolean) => void;
canUpdate: boolean;
canDelete: boolean;
canResetPassword: boolean;
canUpdateStatus: boolean;
};
export function createLecturerColumns(
@ -31,6 +35,10 @@ export function createLecturerColumns(
handleDeleteClick,
handleResetPassword,
handleUserStatusChange,
canUpdate,
canDelete,
canResetPassword,
canUpdateStatus,
} = params;
return [
@ -92,6 +100,7 @@ export function createLecturerColumns(
onChange={(isActive) =>
handleUserStatusChange(row.original, isActive)
}
disabled={!canUpdateStatus}
/>
),
},
@ -111,11 +120,13 @@ export function createLecturerColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(lecturer),
},
{
label: 'Reset Kata Sandi',
icon: <Key className="h-4 w-4" />,
show: canResetPassword,
onClick: () => handleResetPassword(lecturer),
},
{
@ -123,6 +134,7 @@ export function createLecturerColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(lecturer),
},
]}

View File

@ -9,6 +9,7 @@ import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
index as lecturersIndex,
@ -47,6 +48,13 @@ export default function LecturerIndex({
}: Props) {
const [deleting, setDeleting] = useState<Lecturer | null>(null);
const [resetting, setResetting] = useState<Lecturer | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-lecturers');
const canUpdate = hasPermission('update-lecturers');
const canDelete = hasPermission('delete-lecturers');
const canResetPassword = hasPermission('reset-lecturers-password');
const canUpdateStatus = hasPermission('update-lecturers-status');
const canExport = hasPermission('export-lecturers');
const filterFields: FilterField[] = [
{
@ -125,6 +133,10 @@ export default function LecturerIndex({
handleDeleteClick: (lecturer) => setDeleting(lecturer),
handleResetPassword: (lecturer) => setResetting(lecturer),
handleUserStatusChange,
canUpdate,
canDelete,
canResetPassword,
canUpdateStatus,
});
return (
@ -136,22 +148,26 @@ export default function LecturerIndex({
title="Dosen"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<a
href={exportLecturers.url({
query: { search, ...filters },
})}
>
<FileSpreadsheet className="h-4 w-4" />
Export
</a>
</Button>
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
{canExport && (
<Button variant="outline" asChild>
<a
href={exportLecturers.url({
query: { search, ...filters },
})}
>
<FileSpreadsheet className="h-4 w-4" />
Export
</a>
</Button>
)}
{canCreate && (
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
)}
</div>
}
/>

View File

@ -29,6 +29,11 @@ type CreateColumnsParams = {
handleStatusChange: (student: Student, status: string) => void;
handleUserStatusChange: (student: Student, isActive: boolean) => void;
statuses: StatusOption[];
canUpdate: boolean;
canDelete: boolean;
canResetPassword: boolean;
canUpdateAcademicStatus: boolean;
canUpdateAccountStatus: boolean;
};
export function createStudentColumns(
@ -41,6 +46,11 @@ export function createStudentColumns(
handleStatusChange,
handleUserStatusChange,
statuses,
canUpdate,
canDelete,
canResetPassword,
canUpdateAcademicStatus,
canUpdateAccountStatus,
} = params;
return [
@ -93,6 +103,7 @@ export function createStudentColumns(
onChange={(status) =>
handleStatusChange(row.original, status)
}
disabled={!canUpdateAcademicStatus}
/>
),
},
@ -117,6 +128,7 @@ export function createStudentColumns(
onChange={(isActive) =>
handleUserStatusChange(row.original, isActive)
}
disabled={!canUpdateAccountStatus}
/>
),
},
@ -136,11 +148,13 @@ export function createStudentColumns(
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(student),
},
{
label: 'Reset Kata Sandi',
icon: <Key className="h-4 w-4" />,
show: canResetPassword,
onClick: () => handleResetPassword(student),
},
{
@ -148,6 +162,7 @@ export function createStudentColumns(
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(student),
},
]}

View File

@ -10,6 +10,7 @@ import { FilterDialog } from '@/components/filter-dialog';
import { PageHeader } from '@/components/page-header';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table';
import {
create,
@ -56,6 +57,18 @@ export default function StudentIndex({
}: Props) {
const [deleting, setDeleting] = useState<Student | null>(null);
const [resetting, setResetting] = useState<Student | null>(null);
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-students');
const canUpdate = hasPermission('update-students');
const canDelete = hasPermission('delete-students');
const canResetPassword = hasPermission('reset-students-password');
const canUpdateAcademicStatus = hasPermission(
'update-students-academic-status',
);
const canUpdateAccountStatus = hasPermission(
'update-students-account-status',
);
const canExport = hasPermission('export-students');
const filterFields: FilterField[] = [
{
@ -157,6 +170,11 @@ export default function StudentIndex({
handleStatusChange,
handleUserStatusChange,
statuses,
canUpdate,
canDelete,
canResetPassword,
canUpdateAcademicStatus,
canUpdateAccountStatus,
});
return (
@ -168,22 +186,26 @@ export default function StudentIndex({
title="Mahasiswa"
actions={
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<a
href={exportStudents.url({
query: { search, ...filters },
})}
>
<FileSpreadsheet className="h-4 w-4" />
Export
</a>
</Button>
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
{canExport && (
<Button variant="outline" asChild>
<a
href={exportStudents.url({
query: { search, ...filters },
})}
>
<FileSpreadsheet className="h-4 w-4" />
Export
</a>
</Button>
)}
{canCreate && (
<Button asChild>
<Link href={create.url()}>
<Plus className="h-4 w-4" />
Tambah
</Link>
</Button>
)}
</div>
}
/>

View File

@ -14,4 +14,5 @@ export type User = {
export type Auth = {
user: User;
permissions?: string[];
};

View File

@ -0,0 +1,27 @@
export const LogLevels = [
'EMERGENCY',
'ALERT',
'CRITICAL',
'ERROR',
'WARNING',
'NOTICE',
'INFO',
'DEBUG',
] as const;
export type LogLevel = (typeof LogLevels)[number];
export type LogEntry = {
id: string;
timestamp: string;
environment: string;
level: LogLevel;
message: string;
raw: string;
};
export type LogFile = {
name: string;
size: number;
modified_at: string;
};

View File

@ -0,0 +1,20 @@
export type RoleWithPermissions = {
id: number;
name: string;
permissions: string[];
};
export type PermissionGroups = Record<string, string[]>;
export const RoleLabels: Record<string, string> = {
mahasiswa: 'Mahasiswa',
dosen: 'Dosen',
'staff-admin': 'Staff Admin',
'staff-keuangan': 'Staff Keuangan',
kaprodi: 'Kaprodi',
developer: 'Developer',
};
export function formatRoleLabel(name: string): string {
return RoleLabels[name] ?? name;
}

View File

@ -5,6 +5,8 @@
use App\Http\Controllers\Admin\AcademicClasses\MaterialController;
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
use App\Http\Controllers\Admin\AcademicClasses\SubmissionController;
use App\Http\Controllers\Admin\Developer\LogController;
use App\Http\Controllers\Admin\Developer\RolePermissionController;
use App\Http\Controllers\Admin\FeedbackController;
use App\Http\Controllers\Admin\Finances\TuitionInvoiceController;
use App\Http\Controllers\Admin\Finances\TuitionPaymentController;
@ -26,62 +28,105 @@
Route::middleware(['auth', 'verified'])->group(function () {
Route::prefix('admin/master')->name('admin.master.')->group(function () {
Route::resource('academic-terms', AcademicTermController::class)->except(['create', 'edit', 'show']);
Route::patch('academic-terms/{academic_term}/status', [AcademicTermController::class, 'updateStatus'])->name('academic-terms.update_status');
Route::resource('departments', DepartmentController::class)->except(['create', 'edit', 'show']);
Route::resource('courses', CourseController::class)->except(['create', 'edit', 'show']);
Route::resource('academic-terms', AcademicTermController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-academic-terms')
->middlewareFor(['store'], 'permission:create-academic-terms')
->middlewareFor(['update'], 'permission:update-academic-terms')
->middlewareFor(['destroy'], 'permission:delete-academic-terms');
Route::patch('academic-terms/{academic_term}/status', [AcademicTermController::class, 'updateStatus'])->name('academic-terms.update_status')->middleware('permission:update-academic-terms-status');
Route::resource('departments', DepartmentController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-departments')
->middlewareFor(['store'], 'permission:create-departments')
->middlewareFor(['update'], 'permission:update-departments')
->middlewareFor(['destroy'], 'permission:delete-departments');
Route::resource('courses', CourseController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-courses')
->middlewareFor(['store'], 'permission:create-courses')
->middlewareFor(['update'], 'permission:update-courses')
->middlewareFor(['destroy'], 'permission:delete-courses');
});
Route::prefix('admin/academic-classes')->name('admin.academic-classes.')->group(function () {
Route::resource('materials', MaterialController::class)->except(['create', 'edit', 'show']);
Route::resource('materials', MaterialController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-materials')
->middlewareFor(['store'], 'permission:create-materials')
->middlewareFor(['update'], 'permission:update-materials')
->middlewareFor(['destroy'], 'permission:delete-materials');
Route::resource('assignments', AssignmentController::class)->except(['create', 'edit', 'show']);
Route::resource('assignments', AssignmentController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-assignments')
->middlewareFor(['store'], 'permission:create-assignments')
->middlewareFor(['update'], 'permission:update-assignments')
->middlewareFor(['destroy'], 'permission:delete-assignments');
Route::prefix('assignments/{assignment}/submissions')->name('assignments.submissions.')->group(function () {
Route::get('/', [SubmissionController::class, 'index'])->name('index');
Route::post('/', [SubmissionController::class, 'store'])->name('store');
Route::put('{submission}', [SubmissionController::class, 'update'])->name('update');
Route::delete('{submission}', [SubmissionController::class, 'destroy'])->name('destroy');
Route::get('/', [SubmissionController::class, 'index'])->name('index')->middleware('permission:view-assignment-submissions');
Route::post('/', [SubmissionController::class, 'store'])->name('store')->middleware('permission:create-assignment-submissions');
Route::put('{submission}', [SubmissionController::class, 'update'])->name('update')->middleware('permission:update-assignment-submissions');
Route::delete('{submission}', [SubmissionController::class, 'destroy'])->name('destroy')->middleware('permission:delete-assignment-submissions');
});
Route::resource('schedules', ScheduleController::class)->except(['create', 'edit', 'show']);
Route::resource('schedules', ScheduleController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-schedules')
->middlewareFor(['store'], 'permission:create-schedules')
->middlewareFor(['update'], 'permission:update-schedules')
->middlewareFor(['destroy'], 'permission:delete-schedules');
Route::prefix('attendances')->name('attendances.')->group(function () {
Route::get('/', [AttendanceController::class, 'index'])->name('index');
Route::get('/', [AttendanceController::class, 'index'])->name('index')->middleware('permission:view-attendances');
Route::get('{course_class}/{meeting_number}', [AttendanceController::class, 'session'])
->whereNumber('meeting_number')
->name('session');
->name('session')
->middleware('permission:view-attendances');
Route::post('{course_class}/{meeting_number}', [AttendanceController::class, 'store'])
->whereNumber('meeting_number')
->name('store');
->name('store')
->middleware('permission:create-attendances');
Route::delete('{course_class}/{meeting_number}', [AttendanceController::class, 'destroy'])
->whereNumber('meeting_number')
->name('destroy');
->name('destroy')
->middleware('permission:delete-attendances');
});
});
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
Route::resource('course-registrations', CourseRegistrationController::class)->only(['index', 'store']);
Route::resource('course-registrations', CourseRegistrationController::class)
->only(['index', 'store'])
->middlewareFor(['index'], 'permission:view-course-registrations')
->middlewareFor(['store'], 'permission:create-course-registrations');
Route::prefix('course-registrations/{submission}')->name('course-registrations.')->group(function () {
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show');
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve');
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject');
Route::get('/', [CourseRegistrationController::class, 'show'])->name('show')->middleware('permission:view-course-registrations');
Route::patch('approve', [CourseRegistrationController::class, 'approve'])->name('approve')->middleware('permission:approve-course-registrations');
Route::patch('reject', [CourseRegistrationController::class, 'reject'])->name('reject')->middleware('permission:reject-course-registrations');
});
Route::resource('course-classes', CourseClassController::class)->except(['create', 'edit', 'show']);
Route::resource('course-classes', CourseClassController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-course-classes')
->middlewareFor(['store'], 'permission:create-course-classes')
->middlewareFor(['update'], 'permission:update-course-classes')
->middlewareFor(['destroy'], 'permission:delete-course-classes');
Route::prefix('course-classes/{course_class}/enrollments')->name('course-classes.enrollments.')->group(function () {
Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index');
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store');
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy');
Route::get('/', [ClassEnrollmentController::class, 'index'])->name('index')->middleware('permission:view-course-class-enrollments');
Route::post('/', [ClassEnrollmentController::class, 'store'])->name('store')->middleware('permission:create-course-class-enrollments');
Route::delete('{enrollment}', [ClassEnrollmentController::class, 'destroy'])->name('destroy')->middleware('permission:delete-course-class-enrollments');
});
Route::prefix('announcements')->name('announcements.')->group(function () {
Route::get('/', [AnnouncementController::class, 'index'])->name('index');
Route::post('/', [AnnouncementController::class, 'store'])->name('store');
Route::put('{announcement}', [AnnouncementController::class, 'update'])->name('update');
Route::delete('{announcement}', [AnnouncementController::class, 'destroy'])->name('destroy');
Route::get('/', [AnnouncementController::class, 'index'])->name('index')->middleware('permission:view-announcements');
Route::post('/', [AnnouncementController::class, 'store'])->name('store')->middleware('permission:create-announcements');
Route::put('{announcement}', [AnnouncementController::class, 'update'])->name('update')->middleware('permission:update-announcements');
Route::delete('{announcement}', [AnnouncementController::class, 'destroy'])->name('destroy')->middleware('permission:delete-announcements');
});
});
@ -107,20 +152,35 @@
});
Route::prefix('admin/finances')->name('admin.finances.')->group(function () {
Route::resource('tuition-invoices', TuitionInvoiceController::class)->except(['create', 'edit', 'show']);
Route::resource('tuition-invoices', TuitionInvoiceController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-tuition-invoices')
->middlewareFor(['store'], 'permission:create-tuition-invoices')
->middlewareFor(['update'], 'permission:update-tuition-invoices')
->middlewareFor(['destroy'], 'permission:delete-tuition-invoices');
Route::prefix('tuition-invoices/{tuition_invoice}/payments')->name('tuition-invoices.payments.')->group(function () {
Route::get('/', [TuitionPaymentController::class, 'index'])->name('index');
Route::post('/', [TuitionPaymentController::class, 'store'])->name('store');
Route::put('{payment}', [TuitionPaymentController::class, 'update'])->name('update');
Route::delete('{payment}', [TuitionPaymentController::class, 'destroy'])->name('destroy');
Route::get('/', [TuitionPaymentController::class, 'index'])->name('index')->middleware('permission:view-tuition-payments');
Route::post('/', [TuitionPaymentController::class, 'store'])->name('store')->middleware('permission:create-tuition-payments');
Route::put('{payment}', [TuitionPaymentController::class, 'update'])->name('update')->middleware('permission:update-tuition-payments');
Route::delete('{payment}', [TuitionPaymentController::class, 'destroy'])->name('destroy')->middleware('permission:delete-tuition-payments');
});
});
Route::prefix('admin/services')->name('admin.services.')->group(function () {
Route::resource('letter-requests', LetterRequestController::class)->except(['create', 'edit', 'show']);
Route::resource('letter-requests', LetterRequestController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-letter-requests')
->middlewareFor(['store'], 'permission:create-letter-requests')
->middlewareFor(['update'], 'permission:update-letter-requests')
->middlewareFor(['destroy'], 'permission:delete-letter-requests');
Route::resource('academic-advising-logs', AcademicAdvisingLogController::class)->except(['create', 'edit', 'show']);
Route::resource('academic-advising-logs', AcademicAdvisingLogController::class)
->except(['create', 'edit', 'show'])
->middlewareFor(['index'], 'permission:view-academic-advising-logs')
->middlewareFor(['store'], 'permission:create-academic-advising-logs')
->middlewareFor(['update'], 'permission:update-academic-advising-logs')
->middlewareFor(['destroy'], 'permission:delete-academic-advising-logs');
});
Route::prefix('admin/settings')->name('admin.settings.')->group(function () {
@ -145,19 +205,45 @@
->names('admin.feedback');
Route::prefix('admin/users')->name('admin.users.')->group(function () {
Route::resource('lecturers', LecturerController::class)->except(['show'])->parameters(['lecturers' => 'user']);
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password');
Route::patch('lecturers/{user}/user-status', [LecturerController::class, 'updateUserStatus'])->name('lecturers.update_user_status');
Route::get('lecturers/export', [LecturerController::class, 'export'])->name('lecturers.export');
Route::resource('lecturers', LecturerController::class)
->except(['show'])
->parameters(['lecturers' => 'user'])
->middlewareFor(['index'], 'permission:view-lecturers')
->middlewareFor(['create', 'store'], 'permission:create-lecturers')
->middlewareFor(['edit', 'update'], 'permission:update-lecturers')
->middlewareFor(['destroy'], 'permission:delete-lecturers');
Route::patch('lecturers/{user}/reset-password', [LecturerController::class, 'resetPassword'])->name('lecturers.reset_password')->middleware('permission:reset-lecturers-password');
Route::patch('lecturers/{user}/user-status', [LecturerController::class, 'updateUserStatus'])->name('lecturers.update_user_status')->middleware('permission:update-lecturers-status');
Route::get('lecturers/export', [LecturerController::class, 'export'])->name('lecturers.export')->middleware('permission:export-lecturers');
Route::resource('students', StudentController::class)->except(['show'])->parameters(['students' => 'user']);
Route::patch('students/{user}/reset-password', [StudentController::class, 'resetPassword'])->name('students.reset_password');
Route::patch('students/{user}/status', [StudentController::class, 'updateStatus'])->name('students.update_status');
Route::patch('students/{user}/user-status', [StudentController::class, 'updateUserStatus'])->name('students.update_user_status');
Route::get('students/export', [StudentController::class, 'export'])->name('students.export');
Route::resource('students', StudentController::class)
->except(['show'])
->parameters(['students' => 'user'])
->middlewareFor(['index'], 'permission:view-students')
->middlewareFor(['create', 'store'], 'permission:create-students')
->middlewareFor(['edit', 'update'], 'permission:update-students')
->middlewareFor(['destroy'], 'permission:delete-students');
Route::patch('students/{user}/reset-password', [StudentController::class, 'resetPassword'])->name('students.reset_password')->middleware('permission:reset-students-password');
Route::patch('students/{user}/status', [StudentController::class, 'updateStatus'])->name('students.update_status')->middleware('permission:update-students-academic-status');
Route::patch('students/{user}/user-status', [StudentController::class, 'updateUserStatus'])->name('students.update_user_status')->middleware('permission:update-students-account-status');
Route::get('students/export', [StudentController::class, 'export'])->name('students.export')->middleware('permission:export-students');
Route::resource('administrators', AdministratorController::class)->except(['show'])->parameters(['administrators' => 'user']);
Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password');
Route::patch('administrators/{user}/user-status', [AdministratorController::class, 'updateUserStatus'])->name('administrators.update_user_status');
Route::resource('administrators', AdministratorController::class)
->except(['show'])
->parameters(['administrators' => 'user'])
->middlewareFor(['index'], 'permission:view-administrators')
->middlewareFor(['create', 'store'], 'permission:create-administrators')
->middlewareFor(['edit', 'update'], 'permission:update-administrators')
->middlewareFor(['destroy'], 'permission:delete-administrators');
Route::patch('administrators/{user}/reset-password', [AdministratorController::class, 'resetPassword'])->name('administrators.reset_password')->middleware('permission:reset-administrators-password');
Route::patch('administrators/{user}/user-status', [AdministratorController::class, 'updateUserStatus'])->name('administrators.update_user_status')->middleware('permission:update-administrators-status');
});
Route::prefix('admin/developer')->name('admin.developer.')->group(function () {
Route::get('logs', [LogController::class, 'index'])->name('logs.index')->middleware('permission:view-logs');
Route::get('logs/download', [LogController::class, 'download'])->name('logs.download')->middleware('permission:view-logs');
Route::get('roles', [RolePermissionController::class, 'index'])->name('roles.index')->middleware('permission:view-roles');
Route::put('roles/{role}', [RolePermissionController::class, 'update'])->name('roles.update')->middleware('permission:update-roles');
});
});