Compare commits

...

37 Commits

Author SHA1 Message Date
Yoga Pangestu
892d14c13a feat: enhance AssignmentIndex; implement infinite scroll and simplify pagination handling 2026-09-04 14:18:09 +07:00
Yoga Pangestu
8fb596fb32 feat: enhance MaterialController and MaterialService; add academic term handling in pagination and index methods 2026-09-04 14:17:04 +07:00
Yoga Pangestu
56d607a131 feat: enhance assignment management; implement new card and column components, improve pagination and filtering, and add lecturer information 2026-09-04 14:13:09 +07:00
Yoga Pangestu
35a64c2045 feat: implement infinite scroll for various admin pages and update pagination handling
- Refactored AdministratorController, LecturerController, and StudentController to utilize Inertia's scroll feature for paginated data.
- Enhanced DataCards and DataTable components to support infinite scrolling, allowing for a smoother user experience when navigating large datasets.
- Updated multiple admin pages (e.g., MaterialIndex, LogIndex, FeedbackIndex) to integrate infinite scroll functionality, improving data loading efficiency.
- Adjusted server table hooks to reset keys appropriately for infinite scroll scenarios.
2026-09-04 13:59:26 +07:00
Yoga Pangestu
33f354ad94 feat: enhance MaterialService and MaterialCard; add lecturer information and improve material display with created date 2026-09-04 13:42:33 +07:00
Yoga Pangestu
139875ce31 feat: enhance AssignmentService and MaterialService; add notification functionality for new assignments and materials 2026-09-04 13:26:41 +07:00
Yoga Pangestu
facf7ab177 feat: enhance AttachmentPreviewDialog integration; add icon variant and improve file display in material cards 2026-09-04 11:51:27 +07:00
Yoga Pangestu
19397d3b5b feat: implement DataCards component and ViewToggle; enhance material display options and pagination handling 2026-09-04 10:58:15 +07:00
Yoga Pangestu
acee293b36 feat: enhance CourseClassService; improve search functionality and refine class retrieval based on user roles 2026-09-04 10:34:28 +07:00
Yoga Pangestu
6ec257a1cc feat: update ViewDetailDialog; conditionally display target roles based on user permissions 2026-09-04 10:16:12 +07:00
Yoga Pangestu
ceeb1e325d feat: update StudentsExport and StudentService; inject User dependency and refine data retrieval methods 2026-09-04 10:10:57 +07:00
Yoga Pangestu
7ea4fb067d feat: refactor attendance management; remove 'mine' method and related components, update permissions and types 2026-09-04 09:56:04 +07:00
Yoga Pangestu
d411085400 feat: update BEST_PRACTICE.md; clarify Eloquent select behavior with joins and aggregate methods 2026-09-04 09:45:18 +07:00
Yoga Pangestu
66bacaf0e1 feat: update CourseClass and CourseRegistration services; refine select queries to improve data retrieval efficiency 2026-09-04 09:33:42 +07:00
Yoga Pangestu
609828a2e2 feat: update Schedule management; refactor role checks to use enums and simplify update method, remove unused fields from Schedule type 2026-09-04 09:33:26 +07:00
Yoga Pangestu
573d834905 feat: implement Assignment and Submission management; add controllers, requests, and services for assignment functionality 2026-09-04 09:17:42 +07:00
Yoga Pangestu
024dc4dd5a feat: enhance Material management; implement authorization checks for material actions and refactor user role handling 2026-09-04 09:00:30 +07:00
Yoga Pangestu
88319dac15 feat: refactor CourseRegistration management; update controller methods, add new request classes, and adjust routes for department leader role 2026-09-04 08:29:03 +07:00
Yoga Pangestu
80435b6cfa feat: implement CourseClass and ClassEnrollment management; add controllers, services, and requests for course enrollment functionality 2026-09-03 19:59:58 +07:00
Yoga Pangestu
f6da193473 refactor: reorganize FeedbackService methods; move delete method and restore pendingCount method for improved clarity 2026-09-03 16:27:33 +07:00
Yoga Pangestu
32c04b91dd feat: implement reply functionality for feedback; add ReplyFeedbackRequest and update FeedbackService; enhance UI for reply actions and display 2026-09-03 16:24:04 +07:00
Yoga Pangestu
900c27041a feat: update Announcement management to support target roles; refactor Announcement model, service, and controller; enhance UI for role-based visibility 2026-09-03 15:45:38 +07:00
Yoga Pangestu
37ce3c15ea feat: enhance AcademicAdvisingLogService to filter logs based on student role; update RolePermissionSeeder to include view-academic-advising-logs permission; rename 'Pembimbing Akademik' to 'Dosen' in UI 2026-09-03 15:05:28 +07:00
Yoga Pangestu
51e943572a feat: update AcademicAdvisingLogController and AcademicAdvisingLogService to accept User parameter for improved access control; enhance query scoping based on user roles 2026-09-03 14:44:05 +07:00
Yoga Pangestu
80997b97b4 feat: enhance LetterRequestService and UI to support lecturer information; update pagination and column display for improved clarity 2026-09-03 14:26:43 +07:00
Yoga Pangestu
db56ec94ea feat: add upload file functionality to letter requests; integrate AttachmentPreviewDialog and update column actions 2026-09-03 14:07:37 +07:00
Yoga Pangestu
aa50487bfc feat: update LetterRequestController and LetterRequestService to accept User parameter for improved access control; add letter request permissions in RolePermissionSeeder 2026-09-03 13:58:04 +07:00
Yoga Pangestu
1c901e9103 feat: update CourseService and CourseController to accept User parameter for improved scoping 2026-09-03 13:45:27 +07:00
Yoga Pangestu
2b458e26fa feat: add leadership history dialog and integrate into DepartmentIndex 2026-09-03 13:25:02 +07:00
Yoga Pangestu
f00fab9520 feat: implement DegreeLevel enum and integrate it into DepartmentRequest and DepartmentService 2026-09-03 13:07:35 +07:00
Yoga Pangestu
8055d4ec5d refactor: optimize AcademicTermService methods and improve query handling 2026-09-03 12:52:32 +07:00
Yoga Pangestu
f69ed0bc15 refactor: reorganize StudentService methods and enhance query efficiency with enums 2026-09-03 11:35:27 +07:00
Yoga Pangestu
aa47ec494e feat: enhance Combobox component with multi-select filtering and grouped item handling 2026-09-03 11:33:13 +07:00
Yoga Pangestu
8f9eb5f77a refactor: streamline LecturerService methods and enhance role assignment with enum 2026-09-03 11:25:23 +07:00
Yoga Pangestu
ad5fe33ec6 feat: implement user role management with enum and refactor role handling in administrator services 2026-09-03 11:17:41 +07:00
Yoga Pangestu
3bec716dab feat: add best practices documentation for Laravel projects 2026-09-03 11:02:27 +07:00
Yoga Pangestu
9f717a3441 refactor: update model relationships and request validation rules for consistency 2026-09-03 10:53:48 +07:00
132 changed files with 3183 additions and 1858 deletions

354
BEST_PRACTICE.md Normal file
View File

@ -0,0 +1,354 @@
# Best Practice Laravel
Panduan konvensi kode untuk **semua project** dengan tech stack: **Laravel 13 (PHP 8.3)**, **Inertia + React + TypeScript**, **Laravel Wayfinder**, **Spatie Permission**, **Pest**, **Larastan**, **Laravel Pint**.
Dokumen ini bersifat reusable antar-project — jangan isi dengan nama domain/fitur spesifik satu project saja. Kalau sebuah project punya pengecualian dari aturan di sini, catat pengecualiannya di `CLAUDE.md`/README project tersebut, bukan mengubah dokumen ini.
Semua kode baru (controller, service, request, model, enum, komponen React) **wajib** mengikuti dokumen ini. Saat mengubah kode lama yang menyimpang, samakan dengan konvensi di sini sekalian (boy scout rule), kecuali perubahan di luar scope task yang sedang dikerjakan.
---
## 1. Konsistensi Struktur & Penamaan
- **Nama method harus konsisten** untuk fungsi yang setara. Jangan sampai controller A punya `store()` tapi controller B untuk hal yang sama pakai `save()`.
- **Urutan method harus konsisten** di semua Controller, Service, Model, dan class lain. Urutan baku untuk resource controller:
1. `index()`
2. `create()` (jika ada, hanya untuk non-Inertia modal-less form)
3. `store()`
4. `show()` (jika ada)
5. `edit()` (jika ada)
6. `update()`
7. `destroy()`
8. Method kustom lain (`updateStatus()`, `resetPassword()`, dll) **selalu ditaruh setelah** ketujuh method di atas, bukan disisipkan di tengah.
Contoh salah — method kustom nyempil di antara `update()` dan `destroy()`:
```php
index(), create(), store(), edit(), update(), resetPassword(), destroy()
```
Contoh benar:
```php
index(), create(), store(), edit(), update(), destroy(), resetPassword()
```
- **Organisasi Controller/Request/Service by domain.** Kelompokkan berdasarkan domain bisnis project yang bersangkutan, konsisten di ketiga layer:
```
app/Http/Controllers/{Domain}/{Name}Controller.php
app/Http/Requests/{Domain}/{Name}Request.php
app/Services/{Domain}/{Name}Service.php
```
Domain ditentukan oleh kebutuhan project (mis. `Manage`, `Master`, `Finances`, `Users`), bukan nama fitur individual. Class baru yang berhubungan masuk ke domain yang sudah ada; jangan bikin domain baru untuk satu fitur kecil.
- Ikuti konvensi penamaan Laravel community (PSR-12 + konvensi umum):
| Yang di-nama-i | Gaya | Contoh benar | Contoh salah |
|---|---|---|---|
| Controller | singular | `ArticleController` | `ArticlesController` |
| Route (URI) | plural | `articles/1` | `article/1` |
| Route name | snake_case + dot | `users.show_active` | `users.show-active` |
| Model | singular | `User` | `Users` |
| Relasi hasOne/belongsTo | singular | `articleComment` | `articleComments` |
| Relasi lain (hasMany, dll) | plural | `articleComments` | `articleComment` |
| Tabel | plural, snake_case | `article_comments` | `articleComments` |
| Tabel pivot | singular, alfabetis | `article_user` | `user_article` |
| Kolom tabel | snake_case, tanpa nama model | `meta_title` | `article_meta_title` |
| Foreign key | singular model + `_id` | `article_id` | `id_article` |
| Primary key | - | `id` | `custom_id` |
| Migration | deskriptif | `2017_01_01_000000_create_articles_table` | `2017_01_01_000000_articles` |
| Method | camelCase | `getAll()` | `get_all()` |
| Method resource controller | verba standar | `store()` | `saveArticle()` |
| Method test (Pest) | deskriptif, `it(...)`/`test(...)` | `it('rejects guest from viewing article')` | — |
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
| Collection | deskriptif, plural | `$activeUsers` | `$data` |
| Object tunggal | deskriptif, singular | `$activeUser` | `$obj` |
| File config/lang index | snake_case | `articles_enabled` | `ArticlesEnabled` |
| File view Blade | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
| Komponen React (Inertia page/props) | PascalCase file, camelCase prop | `EditForm.tsx`, `isOpen` | — |
| Contract (interface) | adjective/noun, tanpa prefix `I` | `AuthenticationInterface` | `IAuthentication` |
| Trait | adjective | `Notifiable` | `NotificationTrait` |
| Enum | singular | `UserType` | `UserTypeEnum` |
| FormRequest | singular + `Request` | `UpdateUserRequest` | `UserFormRequest` |
| Seeder | singular | `UserSeeder` | `UsersSeeder` |
---
## 2. Enum
- Semua data opsi yang sudah pasti/tetap valuenya **wajib** pakai native PHP Enum (`enum ... : string`), jangan pakai konstanta class atau string mentah.
- Setiap Enum yang punya representasi UI **wajib** menyediakan method `label(): string` berbahasa Indonesia:
```php
enum OrderStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
public function label(): string
{
return match ($this) {
self::Pending => 'Menunggu',
self::Completed => 'Selesai',
};
}
}
```
- Kalau project punya banyak Enum, buat trait bantu (mis. `HasValues`) untuk method umum seperti `values()`/`options()` supaya tidak duplikat logika di tiap enum.
- Validasi enum di FormRequest pakai `Rule::enum(XxxEnum::class)`, bukan `Rule::in([...])` manual — termasuk kalau ditulis sebagai `Rule::in(XxxEnum::values())`, itu tetap salah karena tidak mendapat pesan error bawaan enum dan gampang lolos review karena polanya mirip valid:
```php
// Bad
'status' => ['required', 'string', Rule::in(OrderStatus::values())],
// Good
'status' => ['required', 'string', Rule::enum(OrderStatus::class)],
```
`Rule::in([...])` tetap sah dipakai untuk daftar nilai tetap yang **belum** dijadikan Enum (mis. sedang dipertimbangkan atau memang bukan kandidat Enum) — tapi begitu ada Enum untuk field itu, wajib pindah ke `Rule::enum()`.
- Kirim enum ke frontend sebagai `value` + `label`, jangan kirim instance PHP mentah ke Inertia props — gunakan `->value` dan `->label()` secara eksplisit atau resource/mapper kecil.
---
## 3. Model & Eloquent
- Gunakan `#[Guarded(['id'])]` (attribute), **bukan** `protected $guarded` atau `#[Fillable]`. Tambahkan kolom lain ke `Guarded` hanya jika memang tidak boleh diisi lewat mass assignment (mis. `last_login_at`).
- **Hapus model/class yang sudah tidak dipakai**, jangan dibiarkan menumpuk. Cek dengan grep nama class-nya (bukan cuma teks labelnya) sebelum menyimpulkan tidak terpakai — model kosong tanpa `#[Guarded]` dan tanpa referensi pemakaian adalah tanda kuat dead code.
- **Selalu buat relasi dua arah.** Kalau `Order belongsTo Customer`, maka `Customer` juga harus punya relasi baliknya (`hasOne`/`hasMany` sesuai kardinalitas). Jangan biarkan relasi hanya berjalan satu arah.
- **Jangan asumsikan foreign key default Eloquent tanpa mengecek migration.** Sebelum menulis `hasMany`/`hasOne`/`belongsTo` tanpa parameter FK eksplisit, pastikan kolom hasil konvensi (`{model}_id`) memang ada di tabel terkait. Kalau kolomnya berbeda (mis. relasi menyeberang lewat model perantara), gunakan FK eksplisit atau `hasManyThrough`, bukan dibiarkan salah diam-diam.
- **Urutkan method relasi dalam satu model per kelompok tipe, lalu alfabetis di dalam tiap kelompok**, dengan urutan kelompok: `belongsTo``hasOne``hasMany``belongsToMany``hasOneThrough`/`hasManyThrough` → relasi morph (`morphTo`/`morphMany`/`morphToMany`). Method non-relasi (`casts()`, accessor, method bisnis custom) tetap di posisi semula relatif terhadap blok relasi.
```php
// Good — dikelompokkan per tipe, lalu alfabetis
public function department(): BelongsTo { ... } // belongsTo
public function user(): BelongsTo { ... }
public function profile(): HasOne { ... } // hasOne
public function attendances(): HasMany { ... } // hasMany
public function submissions(): HasMany { ... }
public function departments(): BelongsToMany { ... } // belongsToMany
```
- Eloquent-first, hindari raw query/`DB::` kecuali untuk kasus performa spesifik yang benar-benar butuh (agregasi berat, bulk update) — dan beri komentar alasannya.
- **Cegah N+1**: selalu eager-load relasi yang dipakai di view/Inertia props dengan `with()`/`load()`. Saat memakai partial eager load (`with('term:id,start_date,end_date')`), pastikan kolom yang dipakai untuk cast/accessor ikut disertakan, atau serialisasi akan error.
- Mass assignment lewat relasi, bukan set atribut manual satu-satu:
```php
// Bad
$article = new Article;
$article->title = $request->title;
$article->category_id = $category->id;
$article->save();
// Good
$category->articles()->create($request->validated());
```
- Untuk data besar (export, batch update, notifikasi massal), gunakan `chunk()`/`chunkById()`/`cursor()`, jangan `get()` lalu `foreach` penuh di memori:
```php
// Bad
foreach (User::all() as $user) { ... }
// Good
User::chunkById(500, function ($users) {
foreach ($users as $user) { ... }
});
```
- Query builder singkat & ekspresif:
| Panjang | Singkat |
|---|---|
| `->where('column', '=', 1)` | `->where('column', 1)` |
| `->orderBy('created_at', 'desc')` | `->latest()` |
| `->orderBy('created_at', 'asc')` | `->oldest()` |
| `->select('id', 'name')->get()` | `->get(['id', 'name'])` |
| `->first()->name` | `->value('name')` |
Pola `->get([...])`/`->paginate($perPage, [...])` ini berlaku juga walau ada `with()`/`when()`/`join()`/dll di antara — Eloquent tidak peduli di posisi mana `select()` dipanggil relatif ke klausa lain, cuma peduli klausa itu ada sebelum eksekusi. **Kecuali** kalau query yang sama pakai `withCount()`/`withSum()`/`withAvg()`/`withMax()`/`withMin()`: method-method itu diam-diam menyuntik `select(table.*)` kalau belum ada `select()` eksplisit sebelumnya, dan Eloquent hanya menerapkan kolom dari `get($columns)`/`paginate($perPage, $columns)` kalau belum ada `select()` — begitu `withCount()` lebih dulu mengisi kolom, argumen kolom di method terminal **diabaikan diam-diam tanpa error** (balik jadi `select(*)`, bocor semua kolom). Jadi kalau ada `withCount()`/`withSum()`/dst di query, `select([...])` eksplisit **wajib** tetap dipertahankan sebelum pemanggilan `withCount()`/dst, jangan dipindah ke method terminal.
---
## 4. Controller, Request, dan Service
- **Controller** hanya mengatur alur request → response (validasi input dipanggil, service dipanggil, redirect/Inertia render dikembalikan). **Tidak boleh** ada query Eloquent kompleks atau business logic langsung di controller.
- **Selalu gunakan FormRequest**, sekecil apapun validasinya — jangan validasi inline di controller dengan `$request->validate()`.
- Business logic (kalkulasi, orkestrasi antar model, side effect seperti notifikasi/log) **disimpan di Service**, bukan di controller atau model.
- **Kalau method Service butuh user yang sedang login untuk scoping/filtering** (mis. dosen cuma lihat kelasnya sendiri, mahasiswa cuma lihat jurusannya sendiri), terima sebagai parameter eksplisit `User $user` (non-nullable, tanpa default) di **posisi pertama** — jangan panggil `auth()->user()` langsung di dalam Service. Controller yang menyuplainya lewat `$request->user()`. Ini soal testability (Service tidak bergantung diam-diam ke global state) dan konsistensi lintas Service.
```php
// Bad
public function paginated(int $perPage = 25): LengthAwarePaginator
{
$user = auth()->user();
// ...
}
// Good
public function paginated(User $user, int $perPage = 25): LengthAwarePaginator
{
// ...
}
```
- Otorisasi berbasis permission (Spatie) dicek di dua tempat:
- Route-level: middleware `permission:create-xxx` dipasang per-route/per-group.
- Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. Untuk route tanpa FormRequest (mis. `destroy()` yang tidak butuh validasi input), pengecekan yang sama dilakukan inline dengan `abort_if()`/`abort_unless()` di Controller.
- **Predikat kepemilikan itu sendiri ditaruh sebagai method di Model (`User`, atau model pemilik lain yang relevan), bukan didefinisikan ulang di tiap Controller/FormRequest yang butuh.** Satu aturan bisnis harus punya satu sumber kebenaran — supaya konsisten dan gampang di-test. Penamaan: `is<Peran>Of($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage<Model>($target)` untuk gate umum "boleh mengubah/menghapus record ini".
```php
// Bad — predikat yang sama diulang di FormRequest DAN Controller
// (FormRequest)
public function authorize(): bool
{
$assignment = $this->route('assignment');
return $this->user()->can('update-assignments')
&& (! $this->user()->hasRole('dosen') || $assignment->courseClass->lecturer_id === $this->user()->lecturer?->id);
}
// (Controller::destroy(), butuh predikat yang sama karena tidak ada FormRequest)
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
{
$user = request()->user();
abort_if($user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, 403);
}
// Good — predikat di Model, dipakai dari FormRequest maupun Controller
// (User model)
public function canManageAssignment(Assignment $assignment): bool
{
if ($this->hasRole(UserRole::Dosen->value)) {
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
}
return true;
}
// (FormRequest)
public function authorize(): bool
{
return $this->user()->can('update-assignments')
&& $this->user()->canManageAssignment($this->route('assignment'));
}
// (Controller::destroy())
abort_unless(request()->user()->canManageAssignment($assignment), 403);
```
- Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method:
```php
// Bad
$user = new User;
$user->create($request->validated());
// Good
public function __construct(protected UserService $userService) {}
$this->userService->create($request->validated());
```
- **Single Responsibility** — satu method cuma ngerjain satu hal:
```php
// Bad
public function update(Request $request): string
{
$validated = $request->validate([...]);
foreach ($request->events as $event) {
$date = $this->carbon->parse($event['date'])->toString();
$this->logger->log('Update event ' . $date);
}
$this->event->updateGeneralEvent($request->validated());
return back();
}
// Good
public function update(UpdateEventRequest $request): RedirectResponse
{
$this->logService->logEvents($request->events);
$this->eventService->updateGeneralEvent($request->validated());
return back();
}
```
- Pecah method yang melakukan banyak hal jadi beberapa method kecil dengan nama deskriptif:
```php
// Bad
public function getFullNameAttribute(): string
{
if (auth()->user() && auth()->user()->hasRole('client') && auth()->user()->isVerified()) {
return 'Mr. ' . $this->first_name . ' ' . $this->last_name;
}
return $this->first_name[0] . '. ' . $this->last_name;
}
// Good
public function getFullNameAttribute(): string
{
return $this->isVerifiedClient() ? $this->getFullNameLong() : $this->getFullNameShort();
}
public function isVerifiedClient(): bool { ... }
public function getFullNameLong(): string { ... }
public function getFullNameShort(): string { ... }
```
- Return type selalu dideklarasikan secara eksplisit (`: Response`, `: RedirectResponse`, `: Collection`, dll).
- Untuk operasi yang menyentuh >1 tabel sekaligus (mis. buat record + update counter terkait), bungkus dengan `DB::transaction()` di dalam Service, supaya atomik.
---
## 5. Kode Ringkas & Idiomatis Laravel
Gunakan helper singkat yang sudah tersedia, jangan syntax panjang manual:
| Syntax panjang | Syntax ringkas |
|---|---|
| `Session::get('cart')` | `session('cart')` |
| `$request->session()->get('cart')` | `session('cart')` |
| `Session::put('cart', $data)` | `session(['cart' => $data])` |
| `$request->input('name')` | `$request->name` / `request('name')` |
| `return Redirect::back()` | `return back()` |
| `is_null($obj->relation) ? null : $obj->relation->id` | `$obj->relation?->id` |
| `return view('index')->with('title', $t)->with('client', $c)` | `return view('index', compact('title', 'client'))` |
| `$request->has('value') ? $request->value : 'default'` | `$request->get('value', 'default')` |
| `Carbon::now()`, `Carbon::today()` | `now()`, `today()` |
| `App::make('Class')` | `app('Class')` |
- **Jangan panggil `env()` di luar file config.** Simpan dulu ke `config/*.php`, lalu akses lewat `config('nama.key')`. Ini berlaku juga untuk kredensial/flag baru yang ditambahkan.
- **DocBlock boleh dipakai kalau memang dibutuhkan dan penting** — bukan default yang dipasang di semua method. Untuk method standar (CRUD biasa, atau apa pun yang sudah jelas maksudnya dari nama method + return/param type PHP), **jangan** tambah DocBlock — itu pemborosan. DocBlock baru layak ditulis untuk kasus yang memang penting, misalnya:
1. **Generic type** yang tidak bisa diekspresikan native PHP, mis. `@return Collection<int, Student>`, `@param array<int, string>`.
2. **Perilaku non-obvious** yang bisa bikin pembaca salah paham kalau tidak dijelaskan: aturan bisnis tersembunyi, constraint yang tidak kelihatan dari nama method, workaround keterbatasan interface/library.
```php
// Bad — DocBlock generik yang cuma mengulang nama method, tidak nambah informasi
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array { ... }
// Good — tanpa DocBlock sama sekali, karena nama + return type sudah cukup jelas
public function rules(): array { ... }
// Good — DocBlock dipertahankan karena memang menjelaskan hal yang tidak
// kelihatan dari signature method (fallback logic yang bisa mengejutkan)
/**
* Falls back to the one with the latest start date if none is explicitly active.
*/
public function getActive(): ?AcademicTerm { ... }
```
- Komentar kode hanya untuk menjelaskan **kenapa**, bukan **apa** — kalau nama variabel/method sudah jelas, jangan tambah komentar.
---
## 6. Frontend (Inertia + React + TypeScript)
- **Selalu jalankan `php artisan wayfinder:generate --with-form`** setelah menambah/mengubah route, controller, atau FormRequest — flag `--with-form` wajib, tanpa itu halaman auth/settings gagal `tsc`.
- Panggil route lewat Wayfinder helper yang di-generate (`import { store } from '@/routes/...'`), **jangan** hardcode string URL di komponen React.
- Props yang dikirim dari controller ke halaman Inertia harus sudah dalam bentuk final untuk UI (enum sudah di-`value`/`label`, tanggal sudah diformat/di-ISO-kan) — jangan lempar model Eloquent mentah tanpa transformasi eksplisit.
- Komponen React: file `PascalCase.tsx`, satu komponen per file untuk komponen yang di-export dan dipakai di tempat lain; komponen kecil khusus halaman boleh co-located di file yang sama.
- Tipe untuk data dari backend didefinisikan eksplisit (interface/type), jangan `any`.
---
## 7. Testing
- Test ditulis dengan **Pest**, bukan gaya PHPUnit class-based, kecuali menyentuh kode lama yang belum dimigrasikan.
- Nama test deskriptif dan menyatakan perilaku, bukan nama method: `it('rejects guest from updating another user's profile')`.
- Untuk endpoint yang dilindungi permission, test **minimal** mencakup: pengguna dengan permission (berhasil), pengguna tanpa permission (403), dan — kalau ada object-level authorization — kasus user lain yang mencoba mengakses record bukan miliknya (ditolak).
- Gunakan factory (`Model::factory()`), jangan insert manual ke DB di test.
- Jalankan `php artisan test` (atau `vendor/bin/pest`) sebelum menganggap task selesai jika ada perubahan pada Service/Controller/Request.
---
## 8. Kualitas Kode & Tooling
- Jalankan **Laravel Pint** (`vendor/bin/pint --parallel`) sebelum commit — style harus konsisten, jangan format manual.
- Jalankan **Larastan** (`vendor/bin/phpstan analyse`) untuk perubahan yang menyentuh tipe/return value; perbaiki temuannya, jangan suppress kecuali benar-benar false positive dan beri alasan.
- Jangan gunakan `@phpstan-ignore` / `@ts-ignore` sebagai jalan pintas — perbaiki akar masalah tipe-nya.
---
## 9. Keamanan
- Validasi selalu di FormRequest (lihat §4), termasuk validasi kepemilikan record lewat `Rule::exists()` yang di-scope, bukan hanya cek ID ada di tabel mana pun.
- Jangan expose data sensitif (password hash, token) lewat Inertia props atau API resource — pastikan `Guarded`/`hidden` pada model sudah benar dan props controller hanya kirim field yang dibutuhkan.
- Upload file lewat satu service upload terpusat per project, jangan tulis logic upload baru per fitur — validasi mime/size selalu di FormRequest terkait.
- Permission baru yang ditambahkan harus terdaftar di seeder permission (Spatie) dan dipasang di route lewat `permission:` middleware.

27
app/Enums/DegreeLevel.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\HasValues;
enum DegreeLevel: string
{
use HasValues;
case D3 = 'D3';
case D4 = 'D4';
case S1 = 'S1';
case S2 = 'S2';
case S3 = 'S3';
public function label(): string
{
return match ($this) {
self::D3 => 'D3 (Diploma Tiga)',
self::D4 => 'D4 (Diploma Empat)',
self::S1 => 'S1 (Sarjana)',
self::S2 => 'S2 (Magister)',
self::S3 => 'S3 (Doktor)',
};
}
}

29
app/Enums/UserRole.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Enums;
use App\Enums\Concerns\HasValues;
enum UserRole: string
{
use HasValues;
case Mahasiswa = 'mahasiswa';
case Dosen = 'dosen';
case Kaprodi = 'kaprodi';
case StaffAdmin = 'staff-admin';
case StaffKeuangan = 'staff-keuangan';
case Developer = 'developer';
public function label(): string
{
return match ($this) {
self::Mahasiswa => 'Mahasiswa',
self::Dosen => 'Dosen',
self::Kaprodi => 'Ketua Program Studi',
self::StaffAdmin => 'Staff Admin',
self::StaffKeuangan => 'Staff Keuangan',
self::Developer => 'Developer',
};
}
}

View File

@ -18,6 +18,7 @@ class StudentsExport implements FromCollection, ShouldAutoSize, WithEvents, With
use StyledHeadingRow; use StyledHeadingRow;
public function __construct( public function __construct(
private readonly User $user,
private readonly string $search = '', private readonly string $search = '',
private readonly ?string $gender = null, private readonly ?string $gender = null,
private readonly ?int $departmentId = null, private readonly ?int $departmentId = null,
@ -32,7 +33,7 @@ public function title(): string
public function collection(): Enumerable public function collection(): Enumerable
{ {
return app(StudentService::class)->forExport($this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear); return app(StudentService::class)->forExport($this->user, $this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear);
} }
public function headings(): array public function headings(): array

View File

@ -1,13 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Admin\AcademicClasses; namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\AssignmentRequest; use App\Http\Requests\Admin\AcademicClasses\Assignment\AssignmentRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Assignment; use App\Models\Assignment;
use App\Services\Admin\AcademicClasses\AssignmentService; use App\Services\Admin\AcademicClasses\Assignment\AssignmentService;
use App\Services\Admin\Manage\CourseClassService; use App\Services\Admin\Manage\CourseClass\CourseClassService;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
@ -23,23 +23,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
$academicTermId = $request->has('academic_term_id')
? $request->validated('academic_term_id')
: $this->academicTermService->getActive()?->id;
return Inertia::render('admin/academic-classes/assignments/index', [ return Inertia::render('admin/academic-classes/assignments/index', [
'assignments' => Inertia::scroll(fn () => $this->service->paginated( 'assignments' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
academicTermId: $academicTermId, academicTermId: $this->academicTermService->getActive()?->id,
)), )),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
'academicTerms' => $this->academicTermService->getAllForSelect(), 'filters' => $request->only(['course_class_id']),
'filters' => [
'course_class_id' => $request->validated('course_class_id'),
'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
],
]); ]);
} }
@ -63,6 +55,8 @@ public function update(AssignmentRequest $request, Assignment $assignment): Redi
public function destroy(Assignment $assignment): RedirectResponse public function destroy(Assignment $assignment): RedirectResponse
{ {
abort_unless(request()->user()->canManageAssignment($assignment), 403);
$this->service->delete($assignment); $this->service->delete($assignment);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();

View File

@ -1,13 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Admin\AcademicClasses; namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest; use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest; use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
use App\Models\Assignment; use App\Models\Assignment;
use App\Models\Submission; use App\Models\Submission;
use App\Services\Admin\AcademicClasses\SubmissionService; use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -20,7 +20,7 @@ public function __construct(
public function index(Assignment $assignment): Response public function index(Assignment $assignment): Response
{ {
$this->abortUnlessLecturerOwnsAssignment($assignment); abort_unless(request()->user()->canManageAssignment($assignment), 403);
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']); $assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
@ -45,17 +45,4 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment)
return to_route('admin.academic-classes.assignments.index'); return to_route('admin.academic-classes.assignments.index');
} }
/**
* A dosen may only manage submissions for classes they lecture.
*/
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
{
$user = request()->user();
abort_if(
$user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id,
403,
);
}
} }

View File

@ -34,17 +34,6 @@ public function index(Request $request): Response
]); ]);
} }
public function mine(Request $request): Response
{
$student = $request->user()->student;
abort_if(! $student, 403);
return Inertia::render('admin/academic-classes/attendances/mine', [
'summaries' => $this->service->forStudent($student),
]);
}
public function show(CourseClass $courseClass): Response public function show(CourseClass $courseClass): Response
{ {
$this->abortUnlessLecturerOwnsClass($courseClass); $this->abortUnlessLecturerOwnsClass($courseClass);

View File

@ -7,7 +7,8 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Material; use App\Models\Material;
use App\Services\Admin\AcademicClasses\MaterialService; use App\Services\Admin\AcademicClasses\MaterialService;
use App\Services\Admin\Manage\CourseClassService; use App\Services\Admin\Manage\CourseClass\CourseClassService;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -17,16 +18,18 @@ class MaterialController extends Controller
public function __construct( public function __construct(
private readonly MaterialService $service, private readonly MaterialService $service,
private readonly CourseClassService $courseClassService, private readonly CourseClassService $courseClassService,
private readonly AcademicTermService $academicTermService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/academic-classes/materials/index', [ return Inertia::render('admin/academic-classes/materials/index', [
'materials' => $this->service->paginated( 'materials' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
courseClassId: $request->validated('course_class_id'), courseClassId: $request->validated('course_class_id'),
), academicTermId: $this->academicTermService->getActive()?->id,
)),
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()), 'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
'filters' => $request->only(['course_class_id']), 'filters' => $request->only(['course_class_id']),
]); ]);
@ -52,6 +55,8 @@ public function update(MaterialRequest $request, Material $material): RedirectRe
public function destroy(Material $material): RedirectResponse public function destroy(Material $material): RedirectResponse
{ {
abort_unless(request()->user()->canManageMaterial($material), 403);
$this->service->delete($material); $this->service->delete($material);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil dihapus.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil dihapus.'])->back();

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\AcademicClasses; namespace App\Http\Controllers\Admin\AcademicClasses;
use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\ScheduleRequest; use App\Http\Requests\Admin\AcademicClasses\ScheduleRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
@ -26,7 +27,7 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
$user = $request->user(); $user = $request->user();
$isPersonalView = $user->hasRole('mahasiswa') || $user->hasRole('dosen'); $isPersonalView = $user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value);
$academicTermId = $request->has('academic_term_id') $academicTermId = $request->has('academic_term_id')
? $request->validated('academic_term_id') ? $request->validated('academic_term_id')

View File

@ -22,11 +22,11 @@ public function index(PaginatedRequest $request): Response
return Inertia::render('admin/developer/logs/index', [ return Inertia::render('admin/developer/logs/index', [
'entries' => $file 'entries' => $file
? $this->service->paginated( ? Inertia::scroll(fn () => $this->service->paginated(
$file, $file,
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
level: $request->validated('level'), level: $request->validated('level'),
) ))
: null, : null,
'files' => $files, 'files' => $files,
'selectedFile' => $file, 'selectedFile' => $file,

View File

@ -6,6 +6,7 @@
use App\Enums\FeedbackType; use App\Enums\FeedbackType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Feedback\FeedbackRequest; use App\Http\Requests\Admin\Feedback\FeedbackRequest;
use App\Http\Requests\Admin\Feedback\ReplyFeedbackRequest;
use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest; use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Feedback; use App\Models\Feedback;
@ -24,12 +25,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/feedback/index', [ return Inertia::render('admin/feedback/index', [
'feedbacks' => $this->service->paginated( 'feedbacks' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(), $request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
type: $request->validated('type'), type: $request->validated('type'),
status: $request->validated('status'), status: $request->validated('status'),
), )),
'types' => FeedbackType::options(), 'types' => FeedbackType::options(),
'statuses' => FeedbackStatus::options(), 'statuses' => FeedbackStatus::options(),
'filters' => $request->only(['type', 'status']), 'filters' => $request->only(['type', 'status']),
@ -55,6 +56,7 @@ public function store(FeedbackRequest $request): RedirectResponse
public function edit(Request $request, Feedback $feedback): Response public function edit(Request $request, Feedback $feedback): Response
{ {
abort_unless($feedback->user_id === $request->user()->id, 403); abort_unless($feedback->user_id === $request->user()->id, 403);
abort_unless($feedback->status === FeedbackStatus::Submitted, 403);
return Inertia::render('admin/feedback/edit', [ return Inertia::render('admin/feedback/edit', [
'feedback' => $feedback, 'feedback' => $feedback,
@ -64,8 +66,6 @@ public function edit(Request $request, Feedback $feedback): Response
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
{ {
abort_unless($feedback->user_id === $request->user()->id, 403);
$this->service->update($feedback, $request->validated()); $this->service->update($feedback, $request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']);
@ -73,6 +73,16 @@ public function update(FeedbackRequest $request, Feedback $feedback): RedirectRe
return to_route('admin.feedback.index'); return to_route('admin.feedback.index');
} }
public function destroy(Request $request, Feedback $feedback): RedirectResponse
{
abort_unless($feedback->user_id === $request->user()->id, 403);
abort_unless($feedback->status === FeedbackStatus::Submitted, 403);
$this->service->delete($feedback);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
}
public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $feedback): RedirectResponse public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $feedback): RedirectResponse
{ {
$this->service->updateStatus($feedback, $request->validated('status')); $this->service->updateStatus($feedback, $request->validated('status'));
@ -80,12 +90,10 @@ public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $fee
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back();
} }
public function destroy(Request $request, Feedback $feedback): RedirectResponse public function reply(ReplyFeedbackRequest $request, Feedback $feedback): RedirectResponse
{ {
abort_unless($feedback->user_id === $request->user()->id, 403); $this->service->reply($feedback, $request->validated('reply'));
$this->service->delete($feedback); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Balasan berhasil dikirim.'])->back();
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
} }
} }

View File

@ -38,12 +38,12 @@ public function index(PaginatedRequest $request): Response
$summaryTermId = $requestedTermId ?? $this->academicTermService->getActive()?->id; $summaryTermId = $requestedTermId ?? $this->academicTermService->getActive()?->id;
return Inertia::render('admin/finances/tuition-invoices/index', [ return Inertia::render('admin/finances/tuition-invoices/index', [
'invoices' => $this->service->paginated( 'invoices' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
academicTermId: $requestedTermId, academicTermId: $requestedTermId,
status: $request->validated('status'), status: $request->validated('status'),
paymentMethod: $request->validated('payment_method'), paymentMethod: $request->validated('payment_method'),
), )),
'summary' => $this->service->summary( 'summary' => $this->service->summary(
search: $request->validated('search') ?? '', search: $request->validated('search') ?? '',
academicTermId: $summaryTermId, academicTermId: $summaryTermId,

View File

@ -7,7 +7,6 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Announcement; use App\Models\Announcement;
use App\Services\Admin\Manage\AnnouncementService; use App\Services\Admin\Manage\AnnouncementService;
use App\Services\Admin\Master\DepartmentService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -16,18 +15,15 @@ class AnnouncementController extends Controller
{ {
public function __construct( public function __construct(
private readonly AnnouncementService $service, private readonly AnnouncementService $service,
private readonly DepartmentService $departmentService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/manage/announcements/index', [ return Inertia::render('admin/manage/announcements/index', [
'announcements' => $this->service->paginated( 'announcements' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'), )),
),
'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['department_id']),
]); ]);
} }

View File

@ -1,11 +1,11 @@
<?php <?php
namespace App\Http\Controllers\Admin\Manage; namespace App\Http\Controllers\Admin\Manage\CourseClass;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\ClassEnrollment; use App\Models\ClassEnrollment;
use App\Models\CourseClass; use App\Models\CourseClass;
use App\Services\Admin\Manage\ClassEnrollmentService; use App\Services\Admin\Manage\CourseClass\ClassEnrollmentService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;

View File

@ -1,13 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Admin\Manage; namespace App\Http\Controllers\Admin\Manage\CourseClass;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CourseClassRequest; use App\Http\Requests\Admin\Manage\CourseClass\CourseClassRequest;
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest; use App\Http\Requests\Admin\Manage\CourseClass\DuplicateCourseClassesRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\CourseClass; use App\Models\CourseClass;
use App\Services\Admin\Manage\CourseClassService; use App\Services\Admin\Manage\CourseClass\CourseClassService;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use App\Services\Admin\Master\CourseService; use App\Services\Admin\Master\CourseService;
use App\Services\Admin\Users\LecturerService; use App\Services\Admin\Users\LecturerService;
@ -27,15 +27,15 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/manage/course-classes/index', [ return Inertia::render('admin/manage/course-classes/index', [
'courseClasses' => $this->service->paginated( 'courseClasses' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
academicTermId: $request->validated('academic_term_id'), academicTermId: $request->validated('academic_term_id'),
method: $request->validated('method'), method: $request->validated('method'),
), )),
'courses' => $this->courseService->getAllForSelect(), 'courses' => $this->courseService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(), 'lecturers' => $this->lecturerService->getAllForSelect(),
'academicTerms' => $this->academicTermService->getAllForSelect(), 'academicTerms' => $this->academicTermService->getAllForSelect(),
'courseClassAssignments' => $this->service->getAllForSelect(), 'courseClassAssignments' => $this->service->getAllForSelect($request->user()),
'filters' => $request->only(['academic_term_id', 'method']), 'filters' => $request->only(['academic_term_id', 'method']),
]); ]);
} }

View File

@ -2,10 +2,12 @@
namespace App\Http\Controllers\Admin\Manage; namespace App\Http\Controllers\Admin\Manage;
use App\Enums\UserRole;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest; use App\Http\Requests\Admin\Manage\CourseRegistration\RejectCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\SaveCourseRegistrationRequest; use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsAdvisorCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\SignCourseRegistrationRequest; use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsDepartmentLeaderCourseRegistrationRequest;
use App\Http\Requests\Admin\Manage\CourseRegistration\UpdateCourseRegistrationRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Course; use App\Models\Course;
use App\Models\CourseRegistrationSubmission; use App\Models\CourseRegistrationSubmission;
@ -25,30 +27,27 @@ public function __construct(
public function index(PaginatedRequest $request): Response|RedirectResponse public function index(PaginatedRequest $request): Response|RedirectResponse
{ {
$user = auth()->user(); $user = $request->user();
if ($user->hasRole('mahasiswa')) { if ($user->hasRole(UserRole::Mahasiswa->value)) {
return to_route('admin.manage.course-registrations.show', $user->student->id); return to_route('admin.manage.course-registrations.show', $user->student->id);
} }
$isKaprodi = $user->hasRole('kaprodi');
return Inertia::render('admin/manage/course-registrations/index', [ return Inertia::render('admin/manage/course-registrations/index', [
'departments' => $this->service->departmentSummary(), 'departments' => $this->service->departmentSummary(),
'registrations' => $this->service->paginated( 'registrations' => Inertia::scroll(fn () => $this->service->paginated(
$user,
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'), departmentId: $request->validated('department_id'),
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null, )),
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
),
'filters' => $request->only(['department_id']), 'filters' => $request->only(['department_id']),
]); ]);
} }
public function show(Student $student): Response public function show(Student $student): Response
{ {
$user = auth()->user(); $user = request()->user();
$isStudent = $user->hasRole('mahasiswa'); $isStudent = $user->hasRole(UserRole::Mahasiswa->value);
abort_if($isStudent && $user->student->id !== $student->id, 403); abort_if($isStudent && $user->student->id !== $student->id, 403);
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403); abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
@ -61,7 +60,7 @@ public function show(Student $student): Response
&& $user->can('approve-course-registrations') && $user->can('approve-course-registrations')
&& $user->canReviewCourseRegistrationOf($student); && $user->canReviewCourseRegistrationOf($student);
$canSignAsKaprodi = $user->isKaprodiOf($student); $canSignAsDepartmentLeader = $user->isDepartmentLeaderOf($student);
$canSignAsAdvisor = $user->isAdvisorOf($student); $canSignAsAdvisor = $user->isAdvisorOf($student);
$activeTerm = $this->academicTermService->getActive(); $activeTerm = $this->academicTermService->getActive();
@ -97,12 +96,12 @@ public function show(Student $student): Response
'hasActiveTerm' => $activeTerm !== null, 'hasActiveTerm' => $activeTerm !== null,
'backHref' => $backHref, 'backHref' => $backHref,
'canReview' => $canReview, 'canReview' => $canReview,
'canSignAsKaprodi' => $canSignAsKaprodi, 'canSignAsDepartmentLeader' => $canSignAsDepartmentLeader,
'canSignAsAdvisor' => $canSignAsAdvisor, 'canSignAsAdvisor' => $canSignAsAdvisor,
]); ]);
} }
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse public function update(Student $student, string $semester, UpdateCourseRegistrationRequest $request): RedirectResponse
{ {
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester); $semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
@ -138,23 +137,19 @@ public function save(Student $student, string $semester, SaveCourseRegistrationR
return to_route('admin.manage.course-registrations.show', $student->id); return to_route('admin.manage.course-registrations.show', $student->id);
} }
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse public function signAsDepartmentLeader(Student $student, string $semester, SignAsDepartmentLeaderCourseRegistrationRequest $request): RedirectResponse
{ {
abort_unless(auth()->user()->isKaprodiOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester); $submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsKaprodi($submission, $request->file('signature')); $this->service->signAsDepartmentLeader($submission, $request->file('signature'));
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']);
return to_route('admin.manage.course-registrations.show', $student->id); return to_route('admin.manage.course-registrations.show', $student->id);
} }
public function signAsAdvisor(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse public function signAsAdvisor(Student $student, string $semester, SignAsAdvisorCourseRegistrationRequest $request): RedirectResponse
{ {
abort_unless(auth()->user()->isAdvisorOf($student), 403);
$submission = $this->service->findOrCreateForSemester($student, $semester); $submission = $this->service->findOrCreateForSemester($student, $semester);
$this->service->signAsAdvisor($submission, $request->file('signature')); $this->service->signAsAdvisor($submission, $request->file('signature'));
@ -166,7 +161,11 @@ public function signAsAdvisor(Student $student, string $semester, SignCourseRegi
public function approve(CourseRegistrationSubmission $submission): RedirectResponse public function approve(CourseRegistrationSubmission $submission): RedirectResponse
{ {
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403); abort_unless(
request()->user()->can('approve-course-registrations')
&& request()->user()->canReviewCourseRegistrationOf($submission->student),
403
);
$this->service->approve($submission); $this->service->approve($submission);
@ -175,8 +174,6 @@ public function approve(CourseRegistrationSubmission $submission): RedirectRespo
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
{ {
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
$this->service->reject($submission, $request->validated('reason')); $this->service->reject($submission, $request->validated('reason'));
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back(); return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();

View File

@ -21,11 +21,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/master/academic-terms/index', [ return Inertia::render('admin/master/academic-terms/index', [
'academicTerms' => $this->service->paginated( 'academicTerms' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
semester: $request->validated('semester'), semester: $request->validated('semester'),
isActive: $request->filled('is_active') ? filter_var($request->validated('is_active'), FILTER_VALIDATE_BOOLEAN) : null, isActive: $request->filled('is_active') ? $request->boolean('is_active') : null,
), )),
'filters' => $request->only(['semester', 'is_active']), 'filters' => $request->only(['semester', 'is_active']),
]); ]);
} }

View File

@ -22,11 +22,12 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/master/courses/index', [ return Inertia::render('admin/master/courses/index', [
'courses' => $this->service->paginated( 'courses' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
departmentId: $request->validated('department_id'), departmentId: $request->validated('department_id'),
semesterNumber: $request->validated('semester_number'), semesterNumber: $request->validated('semester_number'),
), )),
'departments' => $this->departmentService->getAllForSelect(), 'departments' => $this->departmentService->getAllForSelect(),
'semesterNumbers' => $this->service->getSemesterNumbers(), 'semesterNumbers' => $this->service->getSemesterNumbers(),
'filters' => $request->only(['department_id', 'semester_number']), 'filters' => $request->only(['department_id', 'semester_number']),

View File

@ -22,7 +22,7 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/master/departments/index', [ return Inertia::render('admin/master/departments/index', [
'departments' => $this->service->paginated(...$request->validatedWithDefaults()), 'departments' => Inertia::scroll(fn () => $this->service->paginated(...$request->validatedWithDefaults())),
'lecturers' => $this->lecturerService->getAllForSelect(), 'lecturers' => $this->lecturerService->getAllForSelect(),
]); ]);
} }

View File

@ -24,10 +24,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/services/academic-advising-logs/index', [ return Inertia::render('admin/services/academic-advising-logs/index', [
'logs' => $this->service->paginated( 'logs' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
lecturerId: $request->validated('lecturer_id'), lecturerId: $request->validated('lecturer_id'),
), )),
'students' => $this->studentService->getAllForSelect(), 'students' => $this->studentService->getAllForSelect(),
'lecturers' => $this->lecturerService->getAllForSelect(), 'lecturers' => $this->lecturerService->getAllForSelect(),
'filters' => $request->only(['lecturer_id']), 'filters' => $request->only(['lecturer_id']),

View File

@ -22,10 +22,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/services/letter-requests/index', [ return Inertia::render('admin/services/letter-requests/index', [
'letterRequests' => $this->service->paginated( 'letterRequests' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
status: $request->validated('status'), status: $request->validated('status'),
), )),
'filters' => $request->only(['status']), 'filters' => $request->only(['status']),
]); ]);
} }
@ -53,7 +54,7 @@ public function update(LetterRequestRequest $request, LetterRequest $letterReque
public function destroy(LetterRequest $letterRequest): RedirectResponse public function destroy(LetterRequest $letterRequest): RedirectResponse
{ {
abort_if($letterRequest->status !== LetterStatus::Submitted->value, 403); abort_if($letterRequest->status !== LetterStatus::Submitted, 403);
$this->service->delete($letterRequest); $this->service->delete($letterRequest);

View File

@ -11,7 +11,6 @@
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\Permission\Models\Role;
class AdministratorController extends Controller class AdministratorController extends Controller
{ {
@ -22,10 +21,10 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/users/administrators/index', [ return Inertia::render('admin/users/administrators/index', [
'administrators' => $this->service->paginated( 'administrators' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
gender: $request->validated('gender'), gender: $request->validated('gender'),
), )),
'filters' => $request->only(['gender']), 'filters' => $request->only(['gender']),
]); ]);
} }
@ -33,7 +32,7 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/users/administrators/create', [ return Inertia::render('admin/users/administrators/create', [
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']), 'roles' => $this->service->availableRoles(),
]); ]);
} }
@ -52,7 +51,7 @@ public function edit(User $user): Response
return Inertia::render('admin/users/administrators/edit', [ return Inertia::render('admin/users/administrators/edit', [
'user' => $user, 'user' => $user,
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']), 'roles' => $this->service->availableRoles(),
]); ]);
} }

View File

@ -26,11 +26,11 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/users/lecturers/index', [ return Inertia::render('admin/users/lecturers/index', [
'lecturers' => $this->service->paginated( 'lecturers' => Inertia::scroll(fn () => $this->service->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
gender: $request->validated('gender'), gender: $request->validated('gender'),
departmentId: $request->validated('department_id'), departmentId: $request->validated('department_id'),
), )),
'departments' => $this->departmentService->getAllForSelect(), 'departments' => $this->departmentService->getAllForSelect(),
'filters' => $request->only(['gender', 'department_id']), 'filters' => $request->only(['gender', 'department_id']),
]); ]);

View File

@ -30,13 +30,14 @@ public function __construct(
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
return Inertia::render('admin/users/students/index', [ return Inertia::render('admin/users/students/index', [
'students' => $this->service->paginated( 'students' => Inertia::scroll(fn () => $this->service->paginated(
$request->user(),
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
gender: $request->validated('gender'), gender: $request->validated('gender'),
departmentId: $request->validated('department_id'), departmentId: $request->validated('department_id'),
status: $request->validated('status'), status: $request->validated('status'),
enrollmentYear: $request->validated('enrollment_year'), enrollmentYear: $request->validated('enrollment_year'),
), )),
'departments' => $this->departmentService->getAllForSelect(), 'departments' => $this->departmentService->getAllForSelect(),
'statuses' => StudentStatus::options(), 'statuses' => StudentStatus::options(),
'enrollmentYears' => $this->service->getEnrollmentYears(), 'enrollmentYears' => $this->service->getEnrollmentYears(),
@ -112,6 +113,7 @@ public function updateUserStatus(UpdateStatusRequest $request, User $user): Redi
public function export(PaginatedRequest $request): BinaryFileResponse public function export(PaginatedRequest $request): BinaryFileResponse
{ {
return Excel::download(new StudentsExport( return Excel::download(new StudentsExport(
user: $request->user(),
search: $request->validated('search') ?? '', search: $request->validated('search') ?? '',
gender: $request->validated('gender'), gender: $request->validated('gender'),
departmentId: $request->validated('department_id'), departmentId: $request->validated('department_id'),

View File

@ -1,8 +1,9 @@
<?php <?php
namespace App\Http\Requests\Admin\AcademicClasses; namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus; use App\Enums\AssignmentStatus;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -10,7 +11,12 @@ class AssignmentRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()->can($this->isMethod('post') ? 'create-assignments' : 'update-assignments'); if ($this->isMethod('post')) {
return $this->user()->can('create-assignments');
}
return $this->user()->can('update-assignments')
&& $this->user()->canManageAssignment($this->route('assignment'));
} }
public function rules(): array public function rules(): array
@ -19,7 +25,7 @@ public function rules(): array
$courseClassRule = Rule::exists('course_classes', 'id'); $courseClassRule = Rule::exists('course_classes', 'id');
if ($user->hasRole('dosen')) { if ($user->hasRole(UserRole::Dosen->value)) {
$courseClassRule->where('lecturer_id', $user->lecturer?->id); $courseClassRule->where('lecturer_id', $user->lecturer?->id);
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use Illuminate\Foundation\Http\FormRequest;
class GradeSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update-assignment-submissions')
&& $this->user()->canManageAssignment($this->route('assignment'));
}
public function rules(): array
{
return [
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
}

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Requests\Admin\AcademicClasses; namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus; use App\Enums\AssignmentStatus;
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;

View File

@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses;
use Illuminate\Foundation\Http\FormRequest;
class GradeSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->user()->can('update-assignment-submissions')) {
return false;
}
$user = $this->user();
$assignment = $this->route('assignment');
if ($user->hasRole('dosen') && $assignment?->courseClass?->lecturer_id !== $user->lecturer?->id) {
return false;
}
return true;
}
public function rules(): array
{
return [
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\AcademicClasses; namespace App\Http\Requests\Admin\AcademicClasses;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -9,7 +10,12 @@ class MaterialRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()->can($this->isMethod('post') ? 'create-materials' : 'update-materials'); if ($this->isMethod('post')) {
return $this->user()->can('create-materials');
}
return $this->user()->can('update-materials')
&& $this->user()->canManageMaterial($this->route('material'));
} }
public function rules(): array public function rules(): array
@ -18,7 +24,7 @@ public function rules(): array
$courseClassRule = Rule::exists('course_classes', 'id'); $courseClassRule = Rule::exists('course_classes', 'id');
if ($user->hasRole('dosen')) { if ($user->hasRole(UserRole::Dosen->value)) {
$courseClassRule->where('lecturer_id', $user->lecturer?->id); $courseClassRule->where('lecturer_id', $user->lecturer?->id);
} }

View File

@ -17,7 +17,7 @@ public function rules(): array
{ {
return [ return [
'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')], 'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')],
'day_of_week' => ['required', 'string', Rule::in(DayOfWeek::values())], 'day_of_week' => ['required', 'string', Rule::enum(DayOfWeek::class)],
'start_time' => ['required', 'date_format:H:i'], 'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i', 'after:start_time'], 'end_time' => ['required', 'date_format:H:i', 'after:start_time'],
'room' => ['nullable', 'string', 'max:20'], 'room' => ['nullable', 'string', 'max:20'],

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\Feedback; namespace App\Http\Requests\Admin\Feedback;
use App\Enums\FeedbackStatus;
use App\Enums\FeedbackType; use App\Enums\FeedbackType;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -10,13 +11,21 @@ class FeedbackRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback'); if ($this->isMethod('post')) {
return $this->user()->can('create-feedback');
}
$feedback = $this->route('feedback');
return $this->user()->can('update-feedback')
&& $feedback->user_id === $this->user()->id
&& $feedback->status === FeedbackStatus::Submitted;
} }
public function rules(): array public function rules(): array
{ {
return [ return [
'type' => ['required', 'string', Rule::in(FeedbackType::values())], 'type' => ['required', 'string', Rule::enum(FeedbackType::class)],
'subject' => ['required', 'string', 'max:150'], 'subject' => ['required', 'string', 'max:150'],
'message' => ['required', 'string'], 'message' => ['required', 'string'],
]; ];

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin\Feedback;
use Illuminate\Foundation\Http\FormRequest;
class ReplyFeedbackRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('reply-feedback');
}
public function rules(): array
{
return [
'reply' => ['required', 'string'],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -17,8 +18,10 @@ public function rules(): array
return [ return [
'title' => ['required', 'string', 'max:200'], 'title' => ['required', 'string', 'max:200'],
'content' => ['required', 'string'], 'content' => ['required', 'string'],
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')], 'target_roles' => ['required', 'array', 'min:1'],
'enrollment_year' => ['nullable', 'integer', 'digits:4'], 'target_roles.*' => [
Rule::enum(UserRole::class)->only([UserRole::Mahasiswa, UserRole::Dosen]),
],
]; ];
} }
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage\CourseClass;
use App\Enums\ClassMethod; use App\Enums\ClassMethod;
use App\Enums\Semester; use App\Enums\Semester;
@ -69,7 +69,7 @@ function ($attribute, $value, $fail) {
}, },
], ],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())], 'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
]; ];
} }
@ -92,7 +92,7 @@ function ($attribute, $value, $fail) {
Rule::exists('lecturer_department', 'lecturer_id')->where('department_id', $departmentId), Rule::exists('lecturer_department', 'lecturer_id')->where('department_id', $departmentId),
], ],
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')], 'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())], 'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
]; ];
} }
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage\CourseClass;
use App\Models\AcademicTerm; use App\Models\AcademicTerm;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
@ -8,7 +8,8 @@ class RejectCourseRegistrationRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()->can('reject-course-registrations'); return $this->user()->can('reject-course-registrations')
&& $this->user()->canReviewCourseRegistrationOf($this->route('submission')->student);
} }
public function rules(): array public function rules(): array
@ -17,11 +18,4 @@ public function rules(): array
'reason' => ['required', 'string', 'max:1000'], 'reason' => ['required', 'string', 'max:1000'],
]; ];
} }
public function messages(): array
{
return [
'reason.required' => 'Alasan penolakan wajib diisi.',
];
}
} }

View File

@ -1,14 +1,14 @@
<?php <?php
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class SignCourseRegistrationRequest extends FormRequest class SignAsAdvisorCourseRegistrationRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->isAdvisorOf($this->route('student'));
} }
public function rules(): array public function rules(): array

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use Illuminate\Foundation\Http\FormRequest;
class SignAsDepartmentLeaderCourseRegistrationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isDepartmentLeaderOf($this->route('student'));
}
public function rules(): array
{
return [
'signature' => ['required', 'file', 'image', 'max:2048'],
];
}
}

View File

@ -1,16 +1,17 @@
<?php <?php
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage\CourseRegistration;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class SaveCourseRegistrationRequest extends FormRequest class UpdateCourseRegistrationRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
$student = $this->route('student'); $student = $this->route('student');
return $this->user()->hasRole('mahasiswa') return $this->user()->hasRole(UserRole::Mahasiswa->value)
&& $this->user()->student?->id === $student?->id; && $this->user()->student?->id === $student?->id;
} }

View File

@ -38,7 +38,7 @@ function ($attribute, $value, $fail) {
'semester' => [ 'semester' => [
'required', 'required',
'string', 'string',
Rule::in(Semester::values()), Rule::enum(Semester::class),
Rule::unique('academic_terms')->where(function ($query) { Rule::unique('academic_terms')->where(function ($query) {
return $query->where('academic_year', $this->input('academic_year')); return $query->where('academic_year', $this->input('academic_year'));
})->ignore($this->route('academic_term')), })->ignore($this->route('academic_term')),

View File

@ -2,6 +2,7 @@
namespace App\Http\Requests\Admin\Master; namespace App\Http\Requests\Admin\Master;
use App\Enums\DegreeLevel;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -22,7 +23,7 @@ public function rules(): array
Rule::unique('departments')->ignore($this->route('department')), Rule::unique('departments')->ignore($this->route('department')),
], ],
'name' => ['required', 'string', 'max:100'], 'name' => ['required', 'string', 'max:100'],
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])], 'degree_level' => ['nullable', 'string', Rule::enum(DegreeLevel::class)],
'lecturer_id' => [ 'lecturer_id' => [
'nullable', 'nullable',
Rule::requiredIf($this->route('department') !== null), Rule::requiredIf($this->route('department') !== null),

View File

@ -17,7 +17,7 @@ public function authorize(): bool
return $this->user()->can('update-letter-requests') return $this->user()->can('update-letter-requests')
&& $letterRequest->user_id === $this->user()->id && $letterRequest->user_id === $this->user()->id
&& $letterRequest->status === LetterStatus::Submitted->value; && $letterRequest->status === LetterStatus::Submitted;
} }
public function rules(): array public function rules(): array

View File

@ -3,6 +3,7 @@
namespace App\Http\Requests\Admin\Users; namespace App\Http\Requests\Admin\Users;
use App\Enums\Gender; use App\Enums\Gender;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -37,13 +38,17 @@ public function rules(): array
? Rule::unique('users') ? Rule::unique('users')
: Rule::unique('users')->ignore($userId), : Rule::unique('users')->ignore($userId),
], ],
'role' => ['required', 'string', Rule::in(['staff-admin', 'staff-keuangan'])], 'role' => [
'required',
'string',
Rule::enum(UserRole::class)->only([UserRole::StaffAdmin, UserRole::StaffKeuangan]),
],
// Profile // Profile
'full_name' => ['required', 'string', 'max:150'], 'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'], 'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'], 'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(Gender::values())], 'gender' => ['required', 'string', Rule::enum(Gender::class)],
'birth_date' => ['required', 'date'], 'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'], 'birth_place' => ['required', 'string', 'max:100'],
]; ];

View File

@ -42,7 +42,7 @@ public function rules(): array
'full_name' => ['required', 'string', 'max:150'], 'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'], 'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'], 'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(Gender::values())], 'gender' => ['required', 'string', Rule::enum(Gender::class)],
'birth_date' => ['required', 'date'], 'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'], 'birth_place' => ['required', 'string', 'max:100'],

View File

@ -43,7 +43,7 @@ public function rules(): array
'full_name' => ['required', 'string', 'max:150'], 'full_name' => ['required', 'string', 'max:150'],
'phone_number' => ['required', 'string', 'max:20'], 'phone_number' => ['required', 'string', 'max:20'],
'address' => ['required', 'string'], 'address' => ['required', 'string'],
'gender' => ['required', 'string', Rule::in(Gender::values())], 'gender' => ['required', 'string', Rule::enum(Gender::class)],
'birth_date' => ['required', 'date'], 'birth_date' => ['required', 'date'],
'birth_place' => ['required', 'string', 'max:100'], 'birth_place' => ['required', 'string', 'max:100'],
@ -79,7 +79,7 @@ public function rules(): array
'status' => [ 'status' => [
'nullable', 'nullable',
'string', 'string',
Rule::in(StudentStatus::values()), Rule::enum(StudentStatus::class),
], ],
]; ];
} }

View File

@ -16,7 +16,7 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
return [ return [
'status' => ['required', 'string', Rule::in(StudentStatus::values())], 'status' => ['required', 'string', Rule::enum(StudentStatus::class)],
]; ];
} }
} }

View File

@ -22,19 +22,19 @@ public function rules(): array
'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'], 'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
'search' => ['nullable', 'string', 'max:255'], 'search' => ['nullable', 'string', 'max:255'],
'highlight' => ['nullable', 'integer'], 'highlight' => ['nullable', 'integer'],
'gender' => ['nullable', 'string', Rule::in(Gender::values())], 'gender' => ['nullable', 'string', Rule::enum(Gender::class)],
'department_id' => ['nullable', 'integer'], 'department_id' => ['nullable', 'integer'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'enrollment_year' => ['nullable', 'integer'], 'enrollment_year' => ['nullable', 'integer'],
'semester_number' => ['nullable', 'integer'], 'semester_number' => ['nullable', 'integer'],
'semester' => ['nullable', 'string', Rule::in(Semester::values())], 'semester' => ['nullable', 'string', Rule::enum(Semester::class)],
'is_active' => ['nullable', Rule::in(['true', 'false'])], 'is_active' => ['nullable', Rule::in(['true', 'false'])],
'academic_term_id' => ['nullable', 'integer'], 'academic_term_id' => ['nullable', 'integer'],
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())], 'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
'course_class_id' => ['nullable', 'integer'], 'course_class_id' => ['nullable', 'integer'],
'lecturer_id' => ['nullable', 'integer'], 'lecturer_id' => ['nullable', 'integer'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())], 'payment_method' => ['nullable', 'string', Rule::enum(PaymentMethod::class)],
'file' => ['nullable', 'string'], 'file' => ['nullable', 'string'],
'level' => ['nullable', 'string'], 'level' => ['nullable', 'string'],
]; ];

View File

@ -13,13 +13,13 @@ class AcademicAdvisingLog extends Model
{ {
use HasFactory; use HasFactory;
public function students(): BelongsToMany
{
return $this->belongsToMany(Student::class);
}
public function lecturer(): BelongsTo public function lecturer(): BelongsTo
{ {
return $this->belongsTo(Lecturer::class); return $this->belongsTo(Lecturer::class);
} }
public function students(): BelongsToMany
{
return $this->belongsToMany(Student::class);
}
} }

View File

@ -16,6 +16,21 @@ class AcademicTerm extends Model
{ {
use HasFactory; use HasFactory;
public function courseClasses(): HasMany
{
return $this->hasMany(CourseClass::class);
}
public function courseRegistrations(): HasMany
{
return $this->hasMany(CourseRegistration::class);
}
public function courseRegistrationSubmissions(): HasMany
{
return $this->hasMany(CourseRegistrationSubmission::class);
}
public function tuitionInvoices(): HasMany public function tuitionInvoices(): HasMany
{ {
return $this->hasMany(TuitionInvoice::class); return $this->hasMany(TuitionInvoice::class);

View File

@ -13,9 +13,11 @@ class Announcement extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
public function department(): BelongsTo protected function casts(): array
{ {
return $this->belongsTo(Department::class); return [
'target_roles' => 'array',
];
} }
public function creator(): BelongsTo public function creator(): BelongsTo

View File

@ -22,6 +22,11 @@ protected function casts(): array
]; ];
} }
public function academicTerm(): BelongsTo
{
return $this->belongsTo(AcademicTerm::class);
}
public function course(): BelongsTo public function course(): BelongsTo
{ {
return $this->belongsTo(Course::class); return $this->belongsTo(Course::class);
@ -32,9 +37,14 @@ public function lecturer(): BelongsTo
return $this->belongsTo(Lecturer::class); return $this->belongsTo(Lecturer::class);
} }
public function academicTerm(): BelongsTo public function assignments(): HasMany
{ {
return $this->belongsTo(AcademicTerm::class); return $this->hasMany(Assignment::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
} }
public function enrollments(): HasMany public function enrollments(): HasMany
@ -47,23 +57,13 @@ public function materials(): HasMany
return $this->hasMany(Material::class); return $this->hasMany(Material::class);
} }
public function assignments(): HasMany public function registrations(): HasMany
{ {
return $this->hasMany(Assignment::class); return $this->hasMany(CourseRegistration::class);
} }
public function schedules(): HasMany public function schedules(): HasMany
{ {
return $this->hasMany(Schedule::class); return $this->hasMany(Schedule::class);
} }
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
public function registrations(): HasMany
{
return $this->hasMany(CourseRegistration::class);
}
} }

View File

@ -12,11 +12,6 @@ class CourseRegistration extends Model
{ {
use HasFactory; use HasFactory;
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function academicTerm(): BelongsTo public function academicTerm(): BelongsTo
{ {
return $this->belongsTo(AcademicTerm::class); return $this->belongsTo(AcademicTerm::class);
@ -27,6 +22,11 @@ public function courseClass(): BelongsTo
return $this->belongsTo(CourseClass::class); return $this->belongsTo(CourseClass::class);
} }
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function submission(): BelongsTo public function submission(): BelongsTo
{ {
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id'); return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');

View File

@ -13,7 +13,7 @@
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])] #[Appends(['signature_url', 'department_leader_signature_url', 'advisor_signature_url'])]
class CourseRegistrationSubmission extends Model implements HasMedia class CourseRegistrationSubmission extends Model implements HasMedia
{ {
use InteractsWithMedia; use InteractsWithMedia;
@ -31,15 +31,10 @@ protected function casts(): array
public function registerMediaCollections(): void public function registerMediaCollections(): void
{ {
$this->addMediaCollection('signature')->singleFile(); $this->addMediaCollection('signature')->singleFile();
$this->addMediaCollection('kaprodi_signature')->singleFile(); $this->addMediaCollection('department_leader_signature')->singleFile();
$this->addMediaCollection('advisor_signature')->singleFile(); $this->addMediaCollection('advisor_signature')->singleFile();
} }
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function academicTerm(): BelongsTo public function academicTerm(): BelongsTo
{ {
return $this->belongsTo(AcademicTerm::class); return $this->belongsTo(AcademicTerm::class);
@ -50,6 +45,11 @@ public function reviewer(): BelongsTo
return $this->belongsTo(User::class, 'reviewed_by'); return $this->belongsTo(User::class, 'reviewed_by');
} }
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function courseRegistrations(): HasMany public function courseRegistrations(): HasMany
{ {
return $this->hasMany(CourseRegistration::class, 'submission_id'); return $this->hasMany(CourseRegistration::class, 'submission_id');
@ -67,10 +67,10 @@ protected function signatureUrl(): Attribute
); );
} }
protected function kaprodiSignatureUrl(): Attribute protected function departmentLeaderSignatureUrl(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null, get: fn () => $this->getFirstMediaUrl('department_leader_signature') ?: null,
); );
} }

View File

@ -17,13 +17,13 @@ protected function casts(): array
]; ];
} }
public function submission(): BelongsTo
{
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
}
public function actor(): BelongsTo public function actor(): BelongsTo
{ {
return $this->belongsTo(User::class, 'actor_id'); return $this->belongsTo(User::class, 'actor_id');
} }
public function submission(): BelongsTo
{
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\DegreeLevel;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -15,14 +16,21 @@ class Department extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
public function lecturers(): BelongsToMany protected function casts(): array
{ {
return $this->belongsToMany(Lecturer::class, 'lecturer_department'); return [
'degree_level' => DegreeLevel::class,
];
} }
public function students(): HasMany public function currentLeader(): HasOne
{ {
return $this->hasMany(Student::class); return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
}
public function courses(): HasMany
{
return $this->hasMany(Course::class);
} }
public function leaderships(): HasMany public function leaderships(): HasMany
@ -30,8 +38,13 @@ public function leaderships(): HasMany
return $this->hasMany(DepartmentLeadership::class); return $this->hasMany(DepartmentLeadership::class);
} }
public function currentLeader(): HasOne public function students(): HasMany
{ {
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at'); return $this->hasMany(Student::class);
}
public function lecturers(): BelongsToMany
{
return $this->belongsToMany(Lecturer::class, 'lecturer_department');
} }
} }

View File

@ -21,16 +21,17 @@ protected function casts(): array
return [ return [
'type' => FeedbackType::class, 'type' => FeedbackType::class,
'status' => FeedbackStatus::class, 'status' => FeedbackStatus::class,
'replied_at' => 'datetime',
]; ];
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function handler(): BelongsTo public function handler(): BelongsTo
{ {
return $this->belongsTo(User::class, 'handled_by'); return $this->belongsTo(User::class, 'handled_by');
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
} }

View File

@ -20,9 +20,9 @@ public function user(): BelongsTo
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function departments(): BelongsToMany public function academicAdvisingLogs(): HasMany
{ {
return $this->belongsToMany(Department::class, 'lecturer_department'); return $this->hasMany(AcademicAdvisingLog::class);
} }
public function advisees(): HasMany public function advisees(): HasMany
@ -30,13 +30,18 @@ public function advisees(): HasMany
return $this->hasMany(Student::class, 'academic_advisor_id'); return $this->hasMany(Student::class, 'academic_advisor_id');
} }
public function courseClasses(): HasMany
{
return $this->hasMany(CourseClass::class);
}
public function leaderships(): HasMany public function leaderships(): HasMany
{ {
return $this->hasMany(DepartmentLeadership::class); return $this->hasMany(DepartmentLeadership::class);
} }
public function academicAdvisingLogs(): HasMany public function departments(): BelongsToMany
{ {
return $this->hasMany(AcademicAdvisingLog::class); return $this->belongsToMany(Department::class, 'lecturer_department');
} }
} }

View File

@ -32,16 +32,16 @@ public function registerMediaCollections(): void
$this->addMediaCollection('letter_result')->singleFile(); $this->addMediaCollection('letter_result')->singleFile();
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function processor(): BelongsTo public function processor(): BelongsTo
{ {
return $this->belongsTo(User::class, 'processed_by'); return $this->belongsTo(User::class, 'processed_by');
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function resultUrl(): Attribute protected function resultUrl(): Attribute
{ {
return Attribute::make( return Attribute::make(

View File

@ -1,10 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Mahasiswa extends Model
{
//
}

View File

@ -19,13 +19,13 @@ protected function casts(): array
]; ];
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creator(): BelongsTo public function creator(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
} }

View File

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])] #[Guarded(['id'])]
@ -22,9 +23,9 @@ protected function casts(): array
]; ];
} }
public function user(): BelongsTo public function academicAdvisor(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
} }
public function department(): BelongsTo public function department(): BelongsTo
@ -32,19 +33,14 @@ public function department(): BelongsTo
return $this->belongsTo(Department::class); return $this->belongsTo(Department::class);
} }
public function academicAdvisor(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(Lecturer::class, 'academic_advisor_id'); return $this->belongsTo(User::class);
} }
public function enrollments(): HasMany public function academicAdvisingLogs(): HasMany
{ {
return $this->hasMany(ClassEnrollment::class); return $this->hasMany(AcademicAdvisingLog::class);
}
public function submissions(): HasMany
{
return $this->hasMany(Submission::class);
} }
public function attendances(): HasMany public function attendances(): HasMany
@ -52,11 +48,6 @@ public function attendances(): HasMany
return $this->hasMany(Attendance::class); return $this->hasMany(Attendance::class);
} }
public function tuitionInvoices(): HasMany
{
return $this->hasMany(TuitionInvoice::class);
}
public function courseRegistrations(): HasMany public function courseRegistrations(): HasMany
{ {
return $this->hasMany(CourseRegistration::class); return $this->hasMany(CourseRegistration::class);
@ -67,13 +58,30 @@ public function courseRegistrationSubmissions(): HasMany
return $this->hasMany(CourseRegistrationSubmission::class); return $this->hasMany(CourseRegistrationSubmission::class);
} }
public function letterRequests(): HasMany public function enrollments(): HasMany
{ {
return $this->hasMany(LetterRequest::class); return $this->hasMany(ClassEnrollment::class);
} }
public function academicAdvisingLogs(): HasMany public function submissions(): HasMany
{ {
return $this->hasMany(AcademicAdvisingLog::class); return $this->hasMany(Submission::class);
}
public function tuitionInvoices(): HasMany
{
return $this->hasMany(TuitionInvoice::class);
}
public function letterRequests(): HasManyThrough
{
return $this->hasManyThrough(
LetterRequest::class,
User::class,
'id',
'user_id',
'user_id',
'id',
);
} }
} }

View File

@ -21,16 +21,16 @@ protected function casts(): array
]; ];
} }
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function academicTerm(): BelongsTo public function academicTerm(): BelongsTo
{ {
return $this->belongsTo(AcademicTerm::class); return $this->belongsTo(AcademicTerm::class);
} }
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function payments(): HasMany public function payments(): HasMany
{ {
return $this->hasMany(TuitionPayment::class, 'invoice_id'); return $this->hasMany(TuitionPayment::class, 'invoice_id');

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@ -38,6 +39,11 @@ protected function fullName(): Attribute
return Attribute::get(fn () => $this->profile?->full_name ?? $this->username); return Attribute::get(fn () => $this->profile?->full_name ?? $this->username);
} }
public function lecturer(): HasOne
{
return $this->hasOne(Lecturer::class);
}
public function profile(): HasOne public function profile(): HasOne
{ {
return $this->hasOne(UserProfile::class); return $this->hasOne(UserProfile::class);
@ -48,14 +54,19 @@ public function student(): HasOne
return $this->hasOne(Student::class); return $this->hasOne(Student::class);
} }
public function lecturer(): HasOne public function courseRegistrationSubmissionLogs(): HasMany
{ {
return $this->hasOne(Lecturer::class); return $this->hasMany(CourseRegistrationSubmissionLog::class, 'actor_id');
} }
public function notifications(): HasMany public function createdAnnouncements(): HasMany
{ {
return $this->hasMany(Notification::class); return $this->hasMany(Announcement::class, 'created_by');
}
public function createdNotifications(): HasMany
{
return $this->hasMany(Notification::class, 'created_by');
} }
public function feedbacks(): HasMany public function feedbacks(): HasMany
@ -63,40 +74,100 @@ public function feedbacks(): HasMany
return $this->hasMany(Feedback::class); return $this->hasMany(Feedback::class);
} }
public function isAdvisorOf(Student $student): bool public function handledFeedbacks(): HasMany
{ {
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id; return $this->hasMany(Feedback::class, 'handled_by');
} }
public function isKaprodiOf(Student $student): bool public function letterRequests(): HasMany
{ {
return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id; return $this->hasMany(LetterRequest::class);
}
public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
}
public function processedLetterRequests(): HasMany
{
return $this->hasMany(LetterRequest::class, 'processed_by');
}
public function recordedTuitionPayments(): HasMany
{
return $this->hasMany(TuitionPayment::class, 'recorded_by');
}
public function reviewedCourseRegistrationSubmissions(): HasMany
{
return $this->hasMany(CourseRegistrationSubmission::class, 'reviewed_by');
}
public function uploads(): HasMany
{
return $this->hasMany(EditorUpload::class, 'uploaded_by');
} }
/** /**
* Gates page access: the student's academic advisor, the kaprodi of * A dosen may only manage (update/delete) materials of classes they lecture.
* their department, or staff roles may view a student's KRS.
*/ */
public function canAccessCourseRegistrationOf(Student $student): bool public function canManageMaterial(Material $material): bool
{ {
if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) { if ($this->hasRole(UserRole::Dosen->value)) {
return $this->isAdvisorOf($student) || $this->isKaprodiOf($student); return $material->courseClass->lecturer_id === $this->lecturer?->id;
} }
return true; return true;
} }
/** /**
* Only the student's academic advisor may approve/reject; the kaprodi * A dosen may only manage assignments (update/delete) and their submissions
* signs but does not decide. Other staff roles retain override access. * (view/grade) for classes they lecture.
*/
public function canManageAssignment(Assignment $assignment): bool
{
if ($this->hasRole(UserRole::Dosen->value)) {
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
}
return true;
}
public function isAdvisorOf(Student $student): bool
{
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
}
public function isDepartmentLeaderOf(Student $student): bool
{
return $this->hasRole(UserRole::Kaprodi->value) && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
}
/**
* Gates page access: the student's academic advisor, the department
* leader of their department, or staff roles may view a student's KRS.
*/
public function canAccessCourseRegistrationOf(Student $student): bool
{
if ($this->hasRole(UserRole::Dosen->value) || $this->hasRole(UserRole::Kaprodi->value)) {
return $this->isAdvisorOf($student) || $this->isDepartmentLeaderOf($student);
}
return true;
}
/**
* Only the student's academic advisor may approve/reject; the department
* leader signs but does not decide. Other staff roles retain override access.
*/ */
public function canReviewCourseRegistrationOf(Student $student): bool public function canReviewCourseRegistrationOf(Student $student): bool
{ {
if ($this->hasRole('dosen')) { if ($this->hasRole(UserRole::Dosen->value)) {
return $this->isAdvisorOf($student); return $this->isAdvisorOf($student);
} }
if ($this->hasRole('kaprodi')) { if ($this->hasRole(UserRole::Kaprodi->value)) {
return false; return false;
} }

View File

@ -1,42 +1,52 @@
<?php <?php
namespace App\Services\Admin\AcademicClasses; namespace App\Services\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus; use App\Enums\AssignmentStatus;
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\Assignment; use App\Models\Assignment;
use App\Models\CourseRegistration;
use App\Models\User; use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
class AssignmentService class AssignmentService
{ {
public function __construct(
private readonly NotificationService $notificationService,
) {}
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
{ {
return Assignment::query() return Assignment::query()
->select(['id', 'course_class_id', 'title', 'description', 'deadline', 'status'])
->withCount([ ->withCount([
'submissions', 'submissions',
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'), 'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
]) ])
->with(['courseClass' => fn ($q) => $q->withCount('enrollments') ->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])]) ->with([
->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) { 'course:id,code,name',
'academicTerm:id,academic_year,semester,start_date,end_date',
'lecturer.user.profile',
])])
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at']) $q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
->where('student_id', $user->student?->id); ->where('student_id', $user->student?->id);
}])) }]))
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId)) ->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId))) ->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
$q->whereHas('registrations', function ($q) use ($user) { $q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id) $q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
}); });
})) }))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
} }
public function create(array $data, ?UploadedFile $file): Assignment public function create(array $data, ?UploadedFile $file): Assignment
@ -53,17 +63,33 @@ public function create(array $data, ?UploadedFile $file): Assignment
$assignment->addMedia($file)->toMediaCollection('assignment_attachment'); $assignment->addMedia($file)->toMediaCollection('assignment_attachment');
} }
$assignment->load('courseClass.course');
$recipientUserIds = CourseRegistration::query()
->where('course_class_id', $assignment->course_class_id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved))
->with('student:id,user_id')
->get()
->pluck('student.user_id');
$this->notificationService->sendToUsers(
$recipientUserIds,
'Tugas Baru',
"Tugas baru \"{$assignment->title}\" telah ditambahkan pada kelas {$assignment->courseClass->course->name}.",
);
return $assignment; return $assignment;
} }
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
{ {
$assignment->course_class_id = $data['course_class_id']; $assignment->update([
$assignment->title = $data['title']; 'course_class_id' => $data['course_class_id'],
$assignment->description = $data['description'] ?? null; 'title' => $data['title'],
$assignment->deadline = $data['deadline']; 'description' => $data['description'] ?? null,
$assignment->status = $data['status'] ?? $assignment->status; 'deadline' => $data['deadline'],
$assignment->update(); 'status' => $data['status'] ?? $assignment->status,
]);
if ($file) { if ($file) {
$assignment->addMedia($file)->toMediaCollection('assignment_attachment'); $assignment->addMedia($file)->toMediaCollection('assignment_attachment');

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Services\Admin\AcademicClasses; namespace App\Services\Admin\AcademicClasses\Assignment;
use App\Enums\SubmissionStatus; use App\Enums\SubmissionStatus;
use App\Models\Assignment; use App\Models\Assignment;

View File

@ -5,10 +5,8 @@
use App\Enums\AttendanceStatus; use App\Enums\AttendanceStatus;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\CourseClass; use App\Models\CourseClass;
use App\Models\Student;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;
class AttendanceService class AttendanceService
{ {
@ -113,35 +111,4 @@ public function deleteSession(int $courseClassId, int $meetingNumber): void
->delete(); ->delete();
} }
/**
* Per-class attendance summary for a student's own classes: every class
* they're enrolled in, with their recorded meetings and a present/total
* tally, even for classes with no attendance taken yet.
*/
public function forStudent(Student $student): BaseCollection
{
$enrollments = $student->enrollments()
->with([
'courseClass.course:id,code,name',
'courseClass.academicTerm:id,academic_year,semester,start_date,end_date',
])
->get();
$recordsByClass = Attendance::query()
->where('student_id', $student->id)
->orderBy('meeting_number')
->get(['course_class_id', 'meeting_number', 'date', 'status'])
->groupBy('course_class_id');
return $enrollments->map(function ($enrollment) use ($recordsByClass) {
$records = $recordsByClass->get($enrollment->course_class_id, new Collection);
return [
'course_class' => $enrollment->courseClass,
'records' => $records->values(),
'present_count' => $records->where('status', AttendanceStatus::Present)->count(),
'total_count' => $records->count(),
];
})->values();
}
} }

View File

@ -3,29 +3,39 @@
namespace App\Services\Admin\AcademicClasses; namespace App\Services\Admin\AcademicClasses;
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\CourseRegistration;
use App\Models\Material; use App\Models\Material;
use App\Models\User; use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
class MaterialService class MaterialService
{ {
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator public function __construct(
private readonly NotificationService $notificationService,
) {}
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
{ {
return Material::query() return Material::query()
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number']) ->with([
->with('courseClass.course:id,code,name') 'courseClass.course:id,code,name',
'courseClass.lecturer.user.profile',
])
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId)) ->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) ->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
$q->whereHas('registrations', function ($q) use ($user) { $q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id) $q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
}); });
})) }))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'course_class_id', 'title', 'description', 'meeting_number', 'created_at']);
} }
public function create(array $data, ?UploadedFile $file): Material public function create(array $data, ?UploadedFile $file): Material
@ -41,16 +51,32 @@ public function create(array $data, ?UploadedFile $file): Material
$material->addMedia($file)->toMediaCollection('materials'); $material->addMedia($file)->toMediaCollection('materials');
} }
$material->load('courseClass.course');
$recipientUserIds = CourseRegistration::query()
->where('course_class_id', $material->course_class_id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved))
->with('student:id,user_id')
->get()
->pluck('student.user_id');
$this->notificationService->sendToUsers(
$recipientUserIds,
'Materi Baru',
"Materi baru \"{$material->title}\" telah ditambahkan pada kelas {$material->courseClass->course->name}.",
);
return $material; return $material;
} }
public function update(Material $material, array $data, ?UploadedFile $file): Material public function update(Material $material, array $data, ?UploadedFile $file): Material
{ {
$material->course_class_id = $data['course_class_id']; $material->update([
$material->title = $data['title']; 'course_class_id' => $data['course_class_id'],
$material->description = $data['description'] ?? null; 'title' => $data['title'],
$material->meeting_number = $data['meeting_number'] ?? null; 'description' => $data['description'] ?? null,
$material->update(); 'meeting_number' => $data['meeting_number'] ?? null,
]);
if ($file) { if ($file) {
$material->addMedia($file)->toMediaCollection('materials'); $material->addMedia($file)->toMediaCollection('materials');

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\AcademicClasses; namespace App\Services\Admin\AcademicClasses;
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\CourseClass; use App\Models\CourseClass;
use App\Models\Schedule; use App\Models\Schedule;
use App\Models\User; use App\Models\User;
@ -13,7 +14,6 @@ class ScheduleService
public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection
{ {
return Schedule::query() return Schedule::query()
->select(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link'])
->with([ ->with([
'courseClass:id,course_id,lecturer_id,academic_term_id,method', 'courseClass:id,course_id,lecturer_id,academic_term_id,method',
'courseClass.course:id,code,name,department_id', 'courseClass.course:id,code,name,department_id',
@ -26,39 +26,14 @@ public function all(User $user, ?int $academicTermId = null, ?int $departmentId
$q->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId)) $q->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
->when($departmentId, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('department_id', $departmentId))) ->when($departmentId, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('department_id', $departmentId)))
->when($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber))) ->when($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber)))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) { ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id) $q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
})) }))
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)); ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
}) })
->orderBy('start_time') ->orderBy('start_time')
->get(); ->get(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link']);
}
/**
* Course classes for the schedule form's picker, grouped by department and
* semester and ordered alphabetically to match the course-class picker
* pattern used elsewhere.
*/
public function courseClassOptions(): Collection
{
return CourseClass::query()
->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id'])
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_id')
->with([
'course:id,code,name,department_id,semester_number',
'course.department:id,name',
'lecturer:id,user_id,lecturer_number',
'lecturer.user:id,username',
'lecturer.user.profile:id,user_id,full_name',
'academicTerm:id,academic_year,semester,start_date,end_date',
])
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get();
} }
public function create(array $data): Schedule public function create(array $data): Schedule
@ -75,13 +50,14 @@ public function create(array $data): Schedule
public function update(Schedule $schedule, array $data): Schedule public function update(Schedule $schedule, array $data): Schedule
{ {
$schedule->course_class_id = $data['course_class_id']; $schedule->update([
$schedule->day_of_week = $data['day_of_week']; 'course_class_id' => $data['course_class_id'],
$schedule->start_time = $data['start_time']; 'day_of_week' => $data['day_of_week'],
$schedule->end_time = $data['end_time']; 'start_time' => $data['start_time'],
$schedule->room = $data['room'] ?? null; 'end_time' => $data['end_time'],
$schedule->online_link = $data['online_link'] ?? null; 'room' => $data['room'] ?? null,
$schedule->update(); 'online_link' => $data['online_link'] ?? null,
]);
return $schedule; return $schedule;
} }
@ -90,4 +66,28 @@ public function delete(Schedule $schedule): bool
{ {
return $schedule->delete(); return $schedule->delete();
} }
/**
* Course classes for the schedule form's picker, grouped by department and
* semester and ordered alphabetically to match the course-class picker
* pattern used elsewhere.
*/
public function courseClassOptions(): Collection
{
return CourseClass::query()
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_id')
->with([
'course:id,code,name,department_id,semester_number',
'course.department:id,name',
'lecturer:id,user_id,lecturer_number',
'lecturer.user:id,username',
'lecturer.user.profile:id,user_id,full_name',
'academicTerm:id,academic_year,semester,start_date,end_date',
])
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
}
} }

View File

@ -41,18 +41,10 @@ public function __construct()
$this->sanitizer = new HtmlSanitizer($config); $this->sanitizer = new HtmlSanitizer($config);
} }
public function pendingCount(User $user): int
{
return Feedback::query()
->where('user_id', $user->id)
->where('status', '!=', FeedbackStatus::Resolved)
->count();
}
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $type = null, ?string $status = null): LengthAwarePaginator public function paginated(User $user, int $perPage = 25, string $search = '', ?string $type = null, ?string $status = null): LengthAwarePaginator
{ {
return Feedback::query() return Feedback::query()
->where('user_id', $user->id) ->when(! $user->can('update-feedback-status'), fn ($q) => $q->where('user_id', $user->id))
->with(['user.profile', 'handler.profile']) ->with(['user.profile', 'handler.profile'])
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
->when($type, fn ($q) => $q->where('type', $type)) ->when($type, fn ($q) => $q->where('type', $type))
@ -73,14 +65,20 @@ public function create(User $user, array $data): Feedback
public function update(Feedback $feedback, array $data): Feedback public function update(Feedback $feedback, array $data): Feedback
{ {
$feedback->type = $data['type']; $feedback->update([
$feedback->subject = $data['subject']; 'type' => $data['type'],
$feedback->message = $this->sanitizer->sanitize($data['message']); 'subject' => $data['subject'],
$feedback->update(); 'message' => $this->sanitizer->sanitize($data['message']),
]);
return $feedback; return $feedback;
} }
public function delete(Feedback $feedback): bool
{
return $feedback->delete();
}
public function updateStatus(Feedback $feedback, string $status): Feedback public function updateStatus(Feedback $feedback, string $status): Feedback
{ {
$feedback->update([ $feedback->update([
@ -91,8 +89,26 @@ public function updateStatus(Feedback $feedback, string $status): Feedback
return $feedback; return $feedback;
} }
public function delete(Feedback $feedback): bool public function reply(Feedback $feedback, string $reply): Feedback
{ {
return $feedback->delete(); $feedback->update([
'reply' => $this->sanitizer->sanitize($reply),
'replied_at' => now(),
'handled_by' => auth()->id(),
'status' => FeedbackStatus::Resolved->value,
]);
return $feedback;
}
public function pendingCount(User $user): int
{
if (! $user->can('update-feedback-status')) {
return 0;
}
return Feedback::query()
->where('status', FeedbackStatus::Submitted)
->count();
} }
} }

View File

@ -2,8 +2,9 @@
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage;
use App\Enums\UserRole;
use App\Models\Announcement; use App\Models\Announcement;
use App\Models\Student; use App\Models\User;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -13,15 +14,15 @@ public function __construct(
private readonly NotificationService $notificationService, private readonly NotificationService $notificationService,
) {} ) {}
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator public function paginated(User $user, int $perPage = 25, string $search = ''): LengthAwarePaginator
{ {
return Announcement::query() return Announcement::query()
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at']) ->with(['creator.profile'])
->with(['department:id,name', 'creator.profile'])
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId)) ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Mahasiswa->value))
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Dosen->value))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'title', 'content', 'target_roles', 'created_by', 'created_at']);
} }
public function create(array $data): Announcement public function create(array $data): Announcement
@ -29,15 +30,13 @@ public function create(array $data): Announcement
$announcement = Announcement::create([ $announcement = Announcement::create([
'title' => $data['title'], 'title' => $data['title'],
'content' => $data['content'], 'content' => $data['content'],
'department_id' => $data['department_id'] ?? null, 'target_roles' => $data['target_roles'],
'enrollment_year' => $data['enrollment_year'] ?? null,
'created_by' => auth()->id(), 'created_by' => auth()->id(),
]); ]);
$recipientUserIds = Student::query() $recipientUserIds = User::query()
->when($announcement->department_id, fn ($q, $departmentId) => $q->where('department_id', $departmentId)) ->whereHas('roles', fn ($q) => $q->whereIn('name', $announcement->target_roles))
->when($announcement->enrollment_year, fn ($q, $year) => $q->where('enrollment_year', $year)) ->pluck('id');
->pluck('user_id');
$this->notificationService->sendToUsers( $this->notificationService->sendToUsers(
$recipientUserIds, $recipientUserIds,
@ -51,11 +50,11 @@ public function create(array $data): Announcement
public function update(Announcement $announcement, array $data): Announcement public function update(Announcement $announcement, array $data): Announcement
{ {
$announcement->title = $data['title']; $announcement->update([
$announcement->content = $data['content']; 'title' => $data['title'],
$announcement->department_id = $data['department_id'] ?? null; 'content' => $data['content'],
$announcement->enrollment_year = $data['enrollment_year'] ?? null; 'target_roles' => $data['target_roles'],
$announcement->update(); ]);
return $announcement; return $announcement;
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage\CourseClass;
use App\Models\ClassEnrollment; use App\Models\ClassEnrollment;
use App\Models\CourseClass; use App\Models\CourseClass;

View File

@ -1,36 +1,20 @@
<?php <?php
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage\CourseClass;
use App\Enums\UserRole;
use App\Models\CourseClass; use App\Models\CourseClass;
use App\Models\User; use App\Models\User;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class CourseClassService class CourseClassService
{ {
/** public function __construct(
* When $user is a dosen, only their own assigned classes are returned. private readonly AcademicTermService $academicTermService,
* When $user is a mahasiswa, only classes from their own department are returned. ) {}
*/
public function getAllForSelect(?User $user = null): Collection
{
return CourseClass::query()
->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id'])
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_id')
->with([
'course:id,code,name,semester_number,department_id',
'course.department:id,name',
])
->when($user?->hasRole('dosen'), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id))
->when($user?->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get();
}
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
{ {
@ -40,7 +24,9 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->join('courses', 'courses.id', '=', 'course_classes.course_id') ->join('courses', 'courses.id', '=', 'course_classes.course_id')
->withCount('enrollments') ->withCount('enrollments')
->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,semester,start_date,end_date']) ->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,semester,start_date,end_date'])
->when($search, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->where(fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))))
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId)) ->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('course_classes.method', $method)) ->when($method, fn ($q) => $q->where('course_classes.method', $method))
->orderByDesc('academic_terms.start_date') ->orderByDesc('academic_terms.start_date')
@ -65,11 +51,12 @@ public function createMany(array $data): Collection
public function update(CourseClass $courseClass, array $data): CourseClass public function update(CourseClass $courseClass, array $data): CourseClass
{ {
$courseClass->course_id = $data['course_id']; $courseClass->update([
$courseClass->lecturer_id = $data['lecturer_id']; 'course_id' => $data['course_id'],
$courseClass->academic_term_id = $data['academic_term_id']; 'lecturer_id' => $data['lecturer_id'],
$courseClass->method = $data['method'] ?? null; 'academic_term_id' => $data['academic_term_id'],
$courseClass->update(); 'method' => $data['method'] ?? null,
]);
return $courseClass; return $courseClass;
} }
@ -121,4 +108,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
return ['created' => $created, 'skipped' => $skipped]; return ['created' => $created, 'skipped' => $skipped];
}); });
} }
/**
* When $user is a dosen, only their own classes in the active academic term are returned.
* When $user is a mahasiswa, only classes from their own department are returned.
*/
public function getAllForSelect(User $user): Collection
{
$isLecturer = $user->hasRole(UserRole::Dosen->value);
$activeAcademicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null;
return CourseClass::query()
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->join('departments', 'departments.id', '=', 'courses.department_id')
->with([
'course:id,code,name,semester_number,department_id',
'course.department:id,name',
])
->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id)
->where('course_classes.academic_term_id', $activeAcademicTermId))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
}
} }

View File

@ -4,6 +4,7 @@
use App\Enums\RegistrationStatus; use App\Enums\RegistrationStatus;
use App\Enums\StudentStatus; use App\Enums\StudentStatus;
use App\Enums\UserRole;
use App\Models\AcademicTerm; use App\Models\AcademicTerm;
use App\Models\ClassEnrollment; use App\Models\ClassEnrollment;
use App\Models\Course; use App\Models\Course;
@ -12,6 +13,7 @@
use App\Models\CourseRegistrationSubmission; use App\Models\CourseRegistrationSubmission;
use App\Models\Department; use App\Models\Department;
use App\Models\Student; use App\Models\Student;
use App\Models\User;
use App\Services\Admin\Master\AcademicTermService; use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -24,24 +26,13 @@ public function __construct(
private readonly AcademicTermService $academicTermService, private readonly AcademicTermService $academicTermService,
) {} ) {}
/** public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
{ {
return Department::query() $isDepartmentLeader = $user->hasRole(UserRole::Kaprodi->value);
->select('id', 'name') $advisorLecturerId = ! $isDepartmentLeader && $user->hasRole(UserRole::Dosen->value) ? $user->lecturer?->id : null;
->orderBy('name') $ledDepartmentIds = $isDepartmentLeader ? $user->ledDepartmentIds() : null;
->get();
}
/**
* @param array<int, int>|null $ledDepartmentIds Restricts results to a kaprodi's own department(s).
*/
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator
{
$paginator = Student::query() $paginator = Student::query()
->select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id'])
->join('departments', 'departments.id', '=', 'students.department_id') ->join('departments', 'departments.id', '=', 'students.department_id')
->join('users', 'users.id', '=', 'students.user_id') ->join('users', 'users.id', '=', 'students.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id') ->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
@ -54,7 +45,7 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
->with(['user.profile', 'department:id,name']) ->with(['user.profile', 'department:id,name'])
->orderBy('departments.name') ->orderBy('departments.name')
->orderBy('user_profiles.full_name') ->orderBy('user_profiles.full_name')
->paginate($perPage); ->paginate($perPage, ['students.id', 'students.user_id', 'students.student_number', 'students.department_id']);
return $paginator->through(fn (Student $student) => [ return $paginator->through(fn (Student $student) => [
'student_id' => $student->id, 'student_id' => $student->id,
@ -219,11 +210,11 @@ public function saveRegistrations(
}); });
} }
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission public function signAsDepartmentLeader(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
{ {
$this->assertSignable($submission); $this->assertSignable($submission);
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature'); $submission->addMedia($signature)->toMediaCollection('department_leader_signature');
return $submission; return $submission;
} }
@ -246,7 +237,7 @@ private function assertSignable(CourseRegistrationSubmission $submission): void
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{ {
abort_unless($submission->status === RegistrationStatus::Submitted, 403); abort_unless($submission->status === RegistrationStatus::Submitted, 403);
abort_if($submission->kaprodi_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.'); abort_if($submission->department_leader_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.'); abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.');
return DB::transaction(function () use ($submission) { return DB::transaction(function () use ($submission) {
@ -299,4 +290,14 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
return $submission; return $submission;
}); });
} }
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
{
return Department::query()
->orderBy('name')
->get(['id', 'name']);
}
} }

View File

@ -9,30 +9,14 @@
class AcademicTermService class AcademicTermService
{ {
public function getAllForSelect(): Collection
{
return AcademicTerm::select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])->latest()->get();
}
/**
* The academic term currently marked active, falling back to the one with
* the latest start date if none is explicitly active.
*/
public function getActive(): ?AcademicTerm
{
return AcademicTerm::where('is_active', true)->first()
?? AcademicTerm::orderByDesc('start_date')->first();
}
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
{ {
return AcademicTerm::query() return AcademicTerm::query()
->select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']) ->when($search, fn ($q) => $q->where('academic_year', 'like', "%{$search}%"))
->when($search, fn($q) => $q->where('academic_year', 'like', "%{$search}%")) ->when($semester, fn ($q) => $q->where('semester', $semester))
->when($semester, fn($q) => $q->where('semester', $semester)) ->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
->when($isActive !== null, fn($q) => $q->where('is_active', $isActive))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
} }
public function create(array $data): AcademicTerm public function create(array $data): AcademicTerm
@ -45,13 +29,14 @@ public function create(array $data): AcademicTerm
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
{ {
$academicTerm->academic_year = $data['academic_year']; $academicTerm->update([
$academicTerm->semester = $data['semester']; 'academic_year' => $data['academic_year'],
$academicTerm->start_date = $data['start_date']; 'semester' => $data['semester'],
$academicTerm->end_date = $data['end_date']; 'start_date' => $data['start_date'],
$academicTerm->is_active = $data['is_active']; 'end_date' => $data['end_date'],
$academicTerm->open_semesters = $this->normalizeOpenSemesters($data['open_semesters'] ?? []); 'is_active' => $data['is_active'],
$academicTerm->update(); 'open_semesters' => $this->normalizeOpenSemesters($data['open_semesters'] ?? []),
]);
return $academicTerm; return $academicTerm;
} }
@ -72,6 +57,21 @@ public function updateActiveStatus(AcademicTerm $academicTerm, bool $isActive):
}); });
} }
public function getAllForSelect(): Collection
{
return AcademicTerm::latest()->get(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
}
/**
* The academic term currently marked active, falling back to the one with
* the latest start date if none is explicitly active.
*/
public function getActive(): ?AcademicTerm
{
return AcademicTerm::where('is_active', true)->first()
?? AcademicTerm::orderByDesc('start_date')->first();
}
/** /**
* @param array<int, mixed> $openSemesters * @param array<int, mixed> $openSemesters
* @return array<int, int> * @return array<int, int>

View File

@ -2,52 +2,28 @@
namespace App\Services\Admin\Master; namespace App\Services\Admin\Master;
use App\Enums\UserRole;
use App\Models\Course; use App\Models\Course;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class CourseService class CourseService
{ {
public function getAllForSelect(): Collection public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
{ {
return Course::query() return Course::query()
->select(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,name')
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get();
}
public function getSemesterNumbers(): array
{
return Course::query()
->select('semester_number')
->whereNotNull('semester_number')
->distinct()
->orderBy('semester_number')
->pluck('semester_number')
->all();
}
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
{
$user = auth()->user();
return Course::query()
->select(['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number'])
->join('departments', 'departments.id', '=', 'courses.department_id') ->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,name') ->with('department:id,name')
->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%"))
->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId)) ->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId))
->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber)) ->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id)) ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? [])) ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
->orderBy('departments.name') ->orderBy('departments.name')
->orderBy('courses.semester_number') ->orderBy('courses.semester_number')
->orderBy('courses.name') ->orderBy('courses.name')
->paginate($perPage); ->paginate($perPage, ['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number']);
} }
public function create(array $data): Course public function create(array $data): Course
@ -57,12 +33,13 @@ public function create(array $data): Course
public function update(Course $course, array $data): Course public function update(Course $course, array $data): Course
{ {
$course->code = $data['code']; $course->update([
$course->name = $data['name']; 'code' => $data['code'],
$course->credits = $data['credits']; 'name' => $data['name'],
$course->department_id = $data['department_id']; 'credits' => $data['credits'],
$course->semester_number = $data['semester_number'] ?? null; 'department_id' => $data['department_id'],
$course->update(); 'semester_number' => $data['semester_number'] ?? null,
]);
return $course; return $course;
} }
@ -71,4 +48,25 @@ public function delete(Course $course): bool
{ {
return $course->delete(); return $course->delete();
} }
public function getAllForSelect(): Collection
{
return Course::query()
->join('departments', 'departments.id', '=', 'courses.department_id')
->with('department:id,name')
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->get(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number']);
}
public function getSemesterNumbers(): array
{
return Course::query()
->whereNotNull('semester_number')
->distinct()
->orderBy('semester_number')
->pluck('semester_number')
->all();
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Master; namespace App\Services\Admin\Master;
use App\Enums\UserRole;
use App\Models\Department; use App\Models\Department;
use App\Models\DepartmentLeadership; use App\Models\DepartmentLeadership;
use App\Models\Lecturer; use App\Models\Lecturer;
@ -10,19 +11,17 @@
class DepartmentService class DepartmentService
{ {
public function getAllForSelect(): Collection
{
return Department::select(['id', 'code', 'name', 'degree_level'])->get();
}
public function paginated(int $perPage = 25, string $search = ''): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = ''): LengthAwarePaginator
{ {
return Department::query() return Department::query()
->select(['id', 'code', 'name', 'degree_level']) ->with([
->with(['currentLeader.lecturer.user.profile']) 'currentLeader.lecturer.user.profile',
'leaderships' => fn ($q) => $q->orderByRaw('ended_at IS NULL DESC')->orderByDesc('started_at'),
'leaderships.lecturer.user.profile',
])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'code', 'name', 'degree_level']);
} }
public function create(array $data): Department public function create(array $data): Department
@ -40,16 +39,27 @@ public function create(array $data): Department
public function update(Department $department, array $data): Department public function update(Department $department, array $data): Department
{ {
$department->code = $data['code']; $department->update([
$department->name = $data['name']; 'code' => $data['code'],
$department->degree_level = $data['degree_level'] ?? null; 'name' => $data['name'],
$department->update(); 'degree_level' => $data['degree_level'] ?? null,
]);
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null); $this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
return $department; return $department;
} }
public function delete(Department $department): bool
{
return $department->delete();
}
public function getAllForSelect(): Collection
{
return Department::get(['id', 'code', 'name', 'degree_level']);
}
private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void
{ {
$currentLeader = DepartmentLeadership::query() $currentLeader = DepartmentLeadership::query()
@ -90,8 +100,8 @@ private function grantKaprodiRole(int $lecturerId): void
{ {
$user = Lecturer::find($lecturerId)?->user; $user = Lecturer::find($lecturerId)?->user;
if ($user && ! $user->hasRole('kaprodi')) { if ($user && ! $user->hasRole(UserRole::Kaprodi->value)) {
$user->assignRole('kaprodi'); $user->assignRole(UserRole::Kaprodi->value);
} }
} }
@ -103,12 +113,7 @@ private function revokeKaprodiRoleIfNoLongerLeading(int $lecturerId): void
->exists(); ->exists();
if (! $stillLeadsAnyDepartment) { if (! $stillLeadsAnyDepartment) {
Lecturer::find($lecturerId)?->user?->removeRole('kaprodi'); Lecturer::find($lecturerId)?->user?->removeRole(UserRole::Kaprodi->value);
} }
} }
public function delete(Department $department): bool
{
return $department->delete();
}
} }

View File

@ -2,18 +2,22 @@
namespace App\Services\Admin\Services; namespace App\Services\Admin\Services;
use App\Enums\UserRole;
use App\Models\AcademicAdvisingLog; use App\Models\AcademicAdvisingLog;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class AcademicAdvisingLogService class AcademicAdvisingLogService
{ {
public function paginated(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator public function paginated(User $user, int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
{ {
$user = auth()->user();
return AcademicAdvisingLog::query() return AcademicAdvisingLog::query()
->select(['id', 'lecturer_id', 'topic', 'notes', 'created_at'])
->with([ ->with([
'students' => function ($q) use ($user) {
if ($user->hasRole(UserRole::Mahasiswa->value)) {
$q->where('students.id', $user->student?->id);
}
},
'students.user.profile', 'students.user.profile',
'students.department', 'students.department',
'lecturer.user.profile', 'lecturer.user.profile',
@ -23,9 +27,10 @@ public function paginated(int $perPage = 25, string $search = '', ?int $lecturer
->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%") ->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))) ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId)) ->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)) ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('students', fn ($q) => $q->where('students.id', $user->student?->id)))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'lecturer_id', 'topic', 'notes', 'created_at']);
} }
public function create(array $data): AcademicAdvisingLog public function create(array $data): AcademicAdvisingLog
@ -43,10 +48,11 @@ public function create(array $data): AcademicAdvisingLog
public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
{ {
$log->lecturer_id = $data['lecturer_id']; $log->update([
$log->topic = $data['topic'] ?? null; 'lecturer_id' => $data['lecturer_id'],
$log->notes = $data['notes'] ?? null; 'topic' => $data['topic'] ?? null,
$log->update(); 'notes' => $data['notes'] ?? null,
]);
$log->students()->sync($data['student_ids']); $log->students()->sync($data['student_ids']);

View File

@ -3,26 +3,26 @@
namespace App\Services\Admin\Services; namespace App\Services\Admin\Services;
use App\Enums\LetterStatus; use App\Enums\LetterStatus;
use App\Enums\UserRole;
use App\Models\LetterRequest; use App\Models\LetterRequest;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
class LetterRequestService class LetterRequestService
{ {
public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator public function paginated(User $user, int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
{ {
$user = auth()->user();
return LetterRequest::query() return LetterRequest::query()
->select(['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at']) ->with(['user.profile', 'user.student.department', 'user.lecturer.departments', 'processor.profile'])
->with(['user.profile', 'user.student.department', 'processor.profile'])
->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%") ->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%")
->orWhereHas('user', fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")) ->orWhereHas('user', fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")))) ->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"))))
->when($status, fn ($q) => $q->where('status', $status)) ->when($status, fn ($q) => $q->where('status', $status))
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('user_id', $user->id)) ->when($user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('user_id', $user->id))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at']);
} }
public function create(array $data): LetterRequest public function create(array $data): LetterRequest
@ -38,9 +38,10 @@ public function create(array $data): LetterRequest
public function update(LetterRequest $letterRequest, array $data): LetterRequest public function update(LetterRequest $letterRequest, array $data): LetterRequest
{ {
$letterRequest->letter_type = $data['letter_type']; $letterRequest->update([
$letterRequest->purpose = $data['purpose'] ?? null; 'letter_type' => $data['letter_type'],
$letterRequest->update(); 'purpose' => $data['purpose'] ?? null,
]);
return $letterRequest; return $letterRequest;
} }
@ -54,12 +55,13 @@ public function updateStatus(LetterRequest $letterRequest, ?string $status, ?Upl
{ {
$status = $result ? LetterStatus::Completed->value : $status; $status = $result ? LetterStatus::Completed->value : $status;
$letterRequest->status = $status; $letterRequest->update([
$letterRequest->processed_by = $status !== LetterStatus::Submitted->value ? auth()->id() : null; 'status' => $status,
$letterRequest->completed_at = $status === LetterStatus::Completed->value 'processed_by' => $status !== LetterStatus::Submitted->value ? auth()->id() : null,
'completed_at' => $status === LetterStatus::Completed->value
? ($letterRequest->completed_at ?? now()) ? ($letterRequest->completed_at ?? now())
: null; : null,
$letterRequest->update(); ]);
if ($result) { if ($result) {
$letterRequest->addMedia($result)->toMediaCollection('letter_result'); $letterRequest->addMedia($result)->toMediaCollection('letter_result');

View File

@ -2,21 +2,28 @@
namespace App\Services\Admin\Users; namespace App\Services\Admin\Users;
use App\Enums\UserRole;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class AdministratorService class AdministratorService
{ {
/**
* @var list<UserRole>
*/
private const ROLES = [UserRole::StaffAdmin, UserRole::StaffKeuangan];
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', ?string $gender = null): LengthAwarePaginator
{ {
return User::select(['id', 'username', 'email', 'is_active']) return User::with([
->with([
'profile:id,user_id,full_name,phone_number,gender', 'profile:id,user_id,full_name,phone_number,gender',
'roles:id,name', 'roles:id,name',
]) ])
->whereHas('roles', fn ($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan'])) ->whereHas('roles', fn ($q) => $q->whereIn('name', array_column(self::ROLES, 'value')))
->when($search, fn ($q) => $q->where(function ($query) use ($search) { ->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%") $query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%")
@ -24,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', ?string $gende
})) }))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender))) ->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'username', 'email', 'is_active']);
} }
public function create(array $data): User public function create(array $data): User
@ -93,4 +100,12 @@ public function updateUserStatus(User $user, bool $isActive): void
{ {
$user->update(['is_active' => $isActive]); $user->update(['is_active' => $isActive]);
} }
/**
* @return Collection<int, Role>
*/
public function availableRoles(): Collection
{
return Role::whereIn('name', array_column(self::ROLES, 'value'))->get(['id', 'name']);
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Users; namespace App\Services\Admin\Users;
use App\Enums\UserRole;
use App\Models\Lecturer; use App\Models\Lecturer;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -12,51 +13,11 @@
class LecturerService class LecturerService
{ {
public function getAllForSelect(): Collection
{
return Lecturer::select(['lecturers.id', 'lecturers.user_id', 'lecturers.lecturer_number'])
->join('users', 'users.id', '=', 'lecturers.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
->with([
'user:id,username',
'user.profile:id,user_id,full_name',
'departments:id,name',
])
->orderBy('user_profiles.full_name')
->get();
}
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null): LengthAwarePaginator
{ {
return $this->filteredQuery($search, $gender, $departmentId) return $this->filteredQuery($search, $gender, $departmentId)
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'username', 'email', 'is_active']);
}
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null): Collection
{
return $this->filteredQuery($search, $gender, $departmentId)
->orderBy('created_at', 'desc')
->get();
}
private function filteredQuery(string $search, ?string $gender, ?int $departmentId): Builder
{
return User::select(['id', 'username', 'email', 'is_active'])
->with([
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
'lecturer:id,user_id,lecturer_number',
'lecturer.departments:id,name',
])
->whereHas('roles', fn ($q) => $q->where('name', 'dosen'))
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('lecturer.departments', fn ($q) => $q->where('departments.id', $departmentId)));
} }
public function create(array $data): User public function create(array $data): User
@ -68,7 +29,7 @@ public function create(array $data): User
'password' => Hash::make(config('app.default_password')), 'password' => Hash::make(config('app.default_password')),
]); ]);
$user->assignRole('dosen'); $user->assignRole(UserRole::Dosen->value);
$user->profile()->create([ $user->profile()->create([
'full_name' => $data['full_name'], 'full_name' => $data['full_name'],
@ -136,4 +97,42 @@ public function updateUserStatus(User $user, bool $isActive): void
{ {
$user->update(['is_active' => $isActive]); $user->update(['is_active' => $isActive]);
} }
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null): Collection
{
return $this->filteredQuery($search, $gender, $departmentId)
->orderBy('created_at', 'desc')
->get(['id', 'username', 'email', 'is_active']);
}
public function getAllForSelect(): Collection
{
return Lecturer::join('users', 'users.id', '=', 'lecturers.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
->with([
'user:id,username',
'user.profile:id,user_id,full_name',
'departments:id,name',
])
->orderBy('user_profiles.full_name')
->get(['lecturers.id', 'lecturers.user_id', 'lecturers.lecturer_number']);
}
private function filteredQuery(string $search, ?string $gender, ?int $departmentId): Builder
{
return User::with([
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
'lecturer:id,user_id,lecturer_number',
'lecturer.departments:id,name',
])
->whereHas('roles', fn ($q) => $q->where('name', UserRole::Dosen->value))
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('lecturer.departments', fn ($q) => $q->where('departments.id', $departmentId)));
}
} }

View File

@ -2,6 +2,8 @@
namespace App\Services\Admin\Users; namespace App\Services\Admin\Users;
use App\Enums\StudentStatus;
use App\Enums\UserRole;
use App\Models\Student; use App\Models\Student;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -12,67 +14,11 @@
class StudentService class StudentService
{ {
public function getAllForSelect(?string $status = null): Collection public function paginated(User $user, int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
{ {
return Student::select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester']) return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
->join('departments', 'departments.id', '=', 'students.department_id')
->join('users', 'users.id', '=', 'students.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
->with([
'user:id,username',
'user.profile:id,user_id,full_name',
'department:id,name',
])
->when($status, fn ($q) => $q->where('students.status', $status))
->orderBy('departments.name')
->orderBy('students.current_semester')
->orderBy('user_profiles.full_name')
->get();
}
public function getEnrollmentYears(): array
{
return Student::query()
->select('enrollment_year')
->distinct()
->orderByDesc('enrollment_year')
->pluck('enrollment_year')
->all();
}
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
{
return $this->filteredQuery($search, $gender, $departmentId, $status, $enrollmentYear)
->latest() ->latest()
->paginate($perPage); ->paginate($perPage, ['id', 'username', 'email', 'is_active']);
}
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): Collection
{
return $this->filteredQuery($search, $gender, $departmentId, $status, $enrollmentYear)
->orderBy('created_at', 'desc')
->get();
}
private function filteredQuery(string $search, ?string $gender, ?int $departmentId, ?string $status, ?int $enrollmentYear = null): Builder
{
return User::select(['id', 'username', 'email', 'is_active'])
->with([
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
'student:id,user_id,student_number,department_id,enrollment_year,current_semester,status',
'student.department:id,name',
])
->whereHas('roles', fn ($q) => $q->where('name', 'mahasiswa'))
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('department_id', $departmentId)))
->when($status, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('status', $status)))
->when($enrollmentYear, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('enrollment_year', $enrollmentYear)));
} }
public function create(array $data): User public function create(array $data): User
@ -84,7 +30,7 @@ public function create(array $data): User
'password' => Hash::make(config('app.default_password')), 'password' => Hash::make(config('app.default_password')),
]); ]);
$user->assignRole('mahasiswa'); $user->assignRole(UserRole::Mahasiswa->value);
$user->profile()->create([ $user->profile()->create([
'full_name' => $data['full_name'], 'full_name' => $data['full_name'],
@ -101,7 +47,7 @@ public function create(array $data): User
'enrollment_year' => $data['enrollment_year'], 'enrollment_year' => $data['enrollment_year'],
'current_semester' => $data['current_semester'], 'current_semester' => $data['current_semester'],
'academic_advisor_id' => $data['academic_advisor_id'], 'academic_advisor_id' => $data['academic_advisor_id'],
'status' => 'active', 'status' => StudentStatus::Active,
]); ]);
return $user; return $user;
@ -162,4 +108,62 @@ public function updateUserStatus(User $user, bool $isActive): void
{ {
$user->update(['is_active' => $isActive]); $user->update(['is_active' => $isActive]);
} }
public function forExport(User $user, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): Collection
{
return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
->orderBy('created_at', 'desc')
->get(['id', 'username', 'email', 'is_active']);
}
public function getAllForSelect(?string $status = null): Collection
{
return Student::join('departments', 'departments.id', '=', 'students.department_id')
->join('users', 'users.id', '=', 'students.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
->with([
'user:id,username',
'user.profile:id,user_id,full_name',
'department:id,name',
])
->when($status, fn ($q) => $q->where('students.status', $status))
->orderBy('departments.name')
->orderBy('students.current_semester')
->orderBy('user_profiles.full_name')
->get(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester']);
}
public function getEnrollmentYears(): array
{
return Student::query()
->distinct()
->orderByDesc('enrollment_year')
->pluck('enrollment_year')
->all();
}
private function filteredQuery(User $user, string $search, ?string $gender, ?int $departmentId, ?string $status, ?int $enrollmentYear = null): Builder
{
$taughtDepartmentIds = $user->hasRole(UserRole::Dosen->value)
? $user->lecturer?->departments()->pluck('departments.id')->all() ?? []
: null;
return User::with([
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
'student:id,user_id,student_number,department_id,enrollment_year,current_semester,status',
'student.department:id,name',
])
->whereHas('roles', fn ($q) => $q->where('name', UserRole::Mahasiswa->value))
->when($taughtDepartmentIds !== null, fn ($q) => $q->whereHas('student', fn ($q) => $q->whereIn('department_id', $taughtDepartmentIds)))
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"));
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->when($departmentId, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('department_id', $departmentId)))
->when($status, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('status', $status)))
->when($enrollmentYear, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('enrollment_year', $enrollmentYear)));
}
} }

View File

@ -18,7 +18,7 @@ class PermissionCatalog
'view-assignment-submissions', 'update-assignment-submissions', 'view-assignment-submissions', 'update-assignment-submissions',
'submit-assignments', 'submit-assignments',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules', 'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
'view-attendances', 'create-attendances', 'delete-attendances', 'view-own-attendances', 'view-attendances', 'create-attendances', 'delete-attendances',
]; ];
public const MANAGE = [ public const MANAGE = [
@ -50,7 +50,7 @@ class PermissionCatalog
]; ];
public const FEEDBACK = [ public const FEEDBACK = [
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'reply-feedback',
]; ];
/** /**

View File

@ -12,8 +12,7 @@ public function up(): void
$table->id(); $table->id();
$table->string('title', 200); $table->string('title', 200);
$table->text('content'); $table->text('content');
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete(); $table->json('target_roles');
$table->integer('enrollment_year')->nullable();
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps(); $table->timestamps();
$table->softDeletes(); $table->softDeletes();

View File

@ -18,7 +18,7 @@ public function up(): void
$table->text('message'); $table->text('message');
$table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value); $table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value);
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete(); $table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('admin_notes')->nullable(); $table->text('reply')->nullable();
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -13,8 +13,7 @@ public function run(): void
[ [
'title' => 'Libur Idul Fitri', 'title' => 'Libur Idul Fitri',
'content' => 'Perkuliahan diliburkan sesuai kalender akademik', 'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
'department_id' => null, 'target_roles' => json_encode(['mahasiswa', 'dosen']),
'enrollment_year' => null,
'created_by' => 5, 'created_by' => 5,
'created_at' => '2026-03-01 08:00:00', 'created_at' => '2026-03-01 08:00:00',
'updated_at' => '2026-03-01 08:00:00', 'updated_at' => '2026-03-01 08:00:00',
@ -22,8 +21,7 @@ public function run(): void
[ [
'title' => 'Jadwal UTS Prodi SI', 'title' => 'Jadwal UTS Prodi SI',
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober', 'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
'department_id' => 1, 'target_roles' => json_encode(['mahasiswa']),
'enrollment_year' => null,
'created_by' => 5, 'created_by' => 5,
'created_at' => '2026-10-01 08:00:00', 'created_at' => '2026-10-01 08:00:00',
'updated_at' => '2026-10-01 08:00:00', 'updated_at' => '2026-10-01 08:00:00',

View File

@ -40,6 +40,7 @@ public function run(): void
'mahasiswa' => [ 'mahasiswa' => [
'view-dashboard', 'view-dashboard',
'view-academic-terms', 'view-academic-terms',
'view-academic-advising-logs',
'view-courses', 'view-courses',
'view-course-registrations', 'view-course-registrations',
'view-letter-requests', 'view-letter-requests',
@ -50,7 +51,7 @@ public function run(): void
'view-materials', 'view-materials',
'view-assignments', 'view-assignments',
'submit-assignments', 'submit-assignments',
'view-own-attendances', 'view-announcements',
...$feedbackSelfService, ...$feedbackSelfService,
], ],
'dosen' => [ 'dosen' => [
@ -75,6 +76,11 @@ public function run(): void
'view-attendances', 'view-attendances',
'create-attendances', 'create-attendances',
'delete-attendances', 'delete-attendances',
'view-letter-requests',
'create-letter-requests',
'update-letter-requests',
'delete-letter-requests',
'view-announcements',
...$feedbackSelfService, ...$feedbackSelfService,
], ],
'staff-admin' => [ 'staff-admin' => [
@ -84,8 +90,9 @@ public function run(): void
...$manage, ...$manage,
...array_diff($services, ['create-letter-requests', 'update-letter-requests']), ...array_diff($services, ['create-letter-requests', 'update-letter-requests']),
...$users, ...$users,
...$feedbackSelfService, 'view-feedback',
'update-feedback-status', 'update-feedback-status',
'reply-feedback',
], ],
'staff-keuangan' => [ 'staff-keuangan' => [
'view-dashboard', 'view-dashboard',

View File

@ -36,10 +36,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments'; import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments';
import { import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances';
index as attendancesRoute,
mine as myAttendancesRoute,
} from '@/routes/admin/academic-classes/attendances';
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials'; import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules'; import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
import { index as logsRoute } from '@/routes/admin/developer/logs'; import { index as logsRoute } from '@/routes/admin/developer/logs';
@ -175,15 +172,6 @@ function buildNavMain({
}, },
] ]
: []), : []),
...(can('view-own-attendances')
? [
{
name: 'Riwayat Kehadiran',
url: myAttendancesRoute.url(),
icon: ClipboardCheck,
},
]
: []),
]; ];
const keuanganItems: NavItem[] = [ const keuanganItems: NavItem[] = [

View File

@ -1,11 +1,17 @@
import { Paperclip } from 'lucide-react'; import { Eye, Paperclip } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
@ -19,12 +25,14 @@ type AttachmentPreviewDialogProps = {
fileUrl: string; fileUrl: string;
fileName: string; fileName: string;
className?: string; className?: string;
variant?: 'link' | 'icon';
}; };
export function AttachmentPreviewDialog({ export function AttachmentPreviewDialog({
fileUrl, fileUrl,
fileName, fileName,
className, className,
variant = 'link',
}: AttachmentPreviewDialogProps) { }: AttachmentPreviewDialogProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const extension = fileExtension(fileName); const extension = fileExtension(fileName);
@ -34,6 +42,22 @@ export function AttachmentPreviewDialog({
return ( return (
<> <>
{variant === 'icon' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={className}
onClick={() => setOpen(true)}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Lihat</TooltipContent>
</Tooltip>
) : (
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
@ -45,6 +69,7 @@ export function AttachmentPreviewDialog({
<Paperclip className="h-3.5 w-3.5 shrink-0" /> <Paperclip className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{fileName}</span> <span className="truncate">{fileName}</span>
</button> </button>
)}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent <DialogContent

View File

@ -0,0 +1,228 @@
import { InfiniteScroll } from '@inertiajs/react';
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react';
import * as React from 'react';
import type { PaginationState } from '@/components/data-table';
import { useDebounce } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export type { PaginationState } from '@/components/data-table';
interface DataCardsProps<TData> {
data: TData[];
renderCard: (item: TData, index: number) => React.ReactNode;
getRowId?: (item: TData) => string | number;
searchKey?: string;
searchPlaceholder?: string;
emptyText?: string;
toolbar?: React.ReactNode;
pagination: PaginationState;
onPageChange?: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
onSearchChange?: (search: string) => void;
searchValue?: string;
/**
* Enables infinite scroll instead of page-based pagination. `propName`
* must match the Inertia page prop returned via `Inertia::scroll()` on
* the backend so the frontend can track its merge/scroll metadata.
*/
infiniteScroll?: {
propName: string;
buffer?: number;
};
}
export function DataCards<TData>({
data,
renderCard,
getRowId,
searchKey,
searchPlaceholder = 'Ketikkan sesuatu...',
emptyText = 'Tidak ada data.',
toolbar,
pagination,
onPageChange,
onPerPageChange,
onSearchChange,
searchValue,
infiniteScroll,
}: DataCardsProps<TData>) {
const isInfiniteScroll = !!infiniteScroll;
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
const [previousSearchValue, setPreviousSearchValue] =
React.useState(searchValue);
if (searchValue !== previousSearchValue) {
setPreviousSearchValue(searchValue);
setLocalSearch(searchValue ?? '');
}
const handleSearchDebounced = useDebounce(
(value: string) => onSearchChange?.(value),
300,
);
function handleSearchChange(value: string) {
setLocalSearch(value);
handleSearchDebounced(value);
}
const currentPage = pagination.current_page;
const totalPages = pagination.last_page;
return (
<>
{(searchKey || toolbar || onPerPageChange) && (
<div className="flex items-center gap-2">
{searchKey && (
<Input
placeholder={searchPlaceholder}
value={localSearch}
onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm"
/>
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{onPerPageChange && (
<Select
value={String(pagination.per_page)}
onValueChange={(value) =>
onPerPageChange(Number(value))
}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="999999">
Semua
</SelectItem>
</SelectContent>
</Select>
)}
</div>
</div>
)}
{data.length ? (
isInfiniteScroll ? (
<InfiniteScroll
data={infiniteScroll.propName}
as="div"
onlyNext
preserveUrl
buffer={infiniteScroll.buffer ?? 300}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
next={({ loading, hasMore }) =>
loading ? (
<p className="py-2 text-center text-sm text-muted-foreground">
Memuat data...
</p>
) : hasMore ? null : (
<p className="py-2 text-center text-sm text-muted-foreground">
Semua data telah ditampilkan.
</p>
)
}
>
{data.map((item, index) => (
<Card
key={getRowId ? getRowId(item) : index}
className="overflow-hidden"
>
<CardContent>
{renderCard(item, index)}
</CardContent>
</Card>
))}
</InfiniteScroll>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{data.map((item, index) => (
<Card
key={getRowId ? getRowId(item) : index}
className="overflow-hidden"
>
<CardContent>
{renderCard(item, index)}
</CardContent>
</Card>
))}
</div>
)
) : (
<Card>
<CardContent className="flex h-24 items-center justify-center text-center text-muted-foreground">
{emptyText}
</CardContent>
</Card>
)}
{isInfiniteScroll ? (
<p className="text-center text-sm text-muted-foreground">
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
{pagination.total.toLocaleString('id-ID')} data
</p>
) : (
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">
{`Halaman ${currentPage} dari ${totalPages}`}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(1)}
disabled={currentPage <= 1}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange?.(totalPages)}
disabled={currentPage >= totalPages}
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
);
}

View File

@ -12,6 +12,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { InfiniteScroll } from '@inertiajs/react';
import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table'; import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table';
import { import {
flexRender, flexRender,
@ -71,6 +72,15 @@ interface DataTableProps<TData, TValue> {
onPerPageChange?: (perPage: number) => void; onPerPageChange?: (perPage: number) => void;
onSearchChange?: (search: string) => void; onSearchChange?: (search: string) => void;
searchValue?: string; searchValue?: string;
/**
* Enables infinite scroll instead of page-based pagination. `propName`
* must match the Inertia page prop returned via `Inertia::scroll()` on
* the backend so the frontend can track its merge/scroll metadata.
*/
infiniteScroll?: {
propName: string;
buffer?: number;
};
} }
const DragHandleContext = React.createContext<{ const DragHandleContext = React.createContext<{
@ -129,7 +139,7 @@ function SortableTableRow({
); );
} }
function useDebounce(callback: (value: string) => void, delay: number) { export function useDebounce(callback: (value: string) => void, delay: number) {
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
return React.useCallback( return React.useCallback(
@ -163,7 +173,10 @@ export function DataTable<TData, TValue>({
onPerPageChange, onPerPageChange,
onSearchChange, onSearchChange,
searchValue, searchValue,
infiniteScroll,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const tbodyRef = React.useRef<HTMLTableSectionElement>(null);
const [expanded, setExpanded] = React.useState<ExpandedState>(() => { const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) { if (!defaultExpanded || !data.length) {
return {}; return {};
@ -183,7 +196,9 @@ export function DataTable<TData, TValue>({
setLocalSearch(searchValue ?? ''); setLocalSearch(searchValue ?? '');
}, [searchValue]); }, [searchValue]);
const isServerMode = !!pagination && !!onPageChange; const isInfiniteScroll = !!infiniteScroll;
const hasServerPager = !isInfiniteScroll && !!pagination && !!onPageChange;
const hasServerSearch = !!onSearchChange;
const isSortable = !!onReorder && !!getRowId; const isSortable = !!onReorder && !!getRowId;
const handleSearchDebounced = useDebounce( const handleSearchDebounced = useDebounce(
@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
header: () => <span className="block text-center">No</span>, header: () => <span className="block text-center">No</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="block text-center"> <span className="block text-center">
{isServerMode {hasServerPager
? (currentPage - 1) * (pagination?.per_page ?? 25) + ? (currentPage - 1) * (pagination?.per_page ?? 25) +
row.index + row.index +
1 1
@ -275,14 +290,14 @@ export function DataTable<TData, TValue>({
function handleSearchChange(value: string) { function handleSearchChange(value: string) {
setLocalSearch(value); setLocalSearch(value);
if (isServerMode) { if (hasServerSearch) {
handleSearchDebounced(value); handleSearchDebounced(value);
} }
} }
return ( return (
<> <>
{(searchKey || toolbar || isServerMode) && ( {(searchKey || toolbar || hasServerPager || isInfiniteScroll) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{searchKey && ( {searchKey && (
<Input <Input
@ -296,7 +311,7 @@ export function DataTable<TData, TValue>({
)} )}
{toolbar} {toolbar}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isServerMode && onPerPageChange && ( {hasServerPager && onPerPageChange && (
<Select <Select
value={String(pagination?.per_page ?? 25)} value={String(pagination?.per_page ?? 25)}
onValueChange={(value) => onValueChange={(value) =>
@ -349,7 +364,7 @@ export function DataTable<TData, TValue>({
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody ref={tbodyRef}>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
isSortable ? ( isSortable ? (
<DndContext <DndContext
@ -407,8 +422,7 @@ export function DataTable<TData, TValue>({
</DndContext> </DndContext>
) : ( ) : (
(() => { (() => {
let previousGroup: string | null = let previousGroup: string | null = null;
null;
return table return table
.getRowModel() .getRowModel()
@ -445,8 +459,7 @@ export function DataTable<TData, TValue>({
> >
{row {row
.getVisibleCells() .getVisibleCells()
.map( .map((cell) => (
(cell) => (
<TableCell <TableCell
key={ key={
cell.id cell.id
@ -471,8 +484,7 @@ export function DataTable<TData, TValue>({
cell.getContext(), cell.getContext(),
)} )}
</TableCell> </TableCell>
), ))}
)}
</TableRow> </TableRow>
{renderSubRow && {renderSubRow &&
row.getIsExpanded() && ( row.getIsExpanded() && (
@ -512,14 +524,43 @@ export function DataTable<TData, TValue>({
</CardContent> </CardContent>
</Card> </Card>
{isInfiniteScroll ? (
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-muted-foreground">
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
{(pagination?.total ?? data.length).toLocaleString(
'id-ID',
)}{' '}
data
</span>
<InfiniteScroll
data={infiniteScroll.propName}
itemsElement={tbodyRef}
onlyNext
preserveUrl
buffer={infiniteScroll.buffer ?? 300}
next={({ loading, hasMore }) =>
loading ? (
<span className="block py-2 text-center text-sm text-muted-foreground">
Memuat data...
</span>
) : hasMore ? null : (
<span className="block py-2 text-center text-sm text-muted-foreground">
Semua data telah ditampilkan.
</span>
)
}
/>
</div>
) : (
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{isServerMode {hasServerPager
? `Halaman ${currentPage} dari ${totalPages}` ? `Halaman ${currentPage} dari ${totalPages}`
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`} : `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isServerMode ? ( {hasServerPager ? (
<> <>
<Button <Button
variant="outline" variant="outline"
@ -532,7 +573,9 @@ export function DataTable<TData, TValue>({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onPageChange(currentPage - 1)} onClick={() =>
onPageChange(currentPage - 1)
}
disabled={currentPage <= 1} disabled={currentPage <= 1}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@ -540,7 +583,9 @@ export function DataTable<TData, TValue>({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onPageChange(currentPage + 1)} onClick={() =>
onPageChange(currentPage + 1)
}
disabled={currentPage >= totalPages} disabled={currentPage >= totalPages}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
@ -584,7 +629,9 @@ export function DataTable<TData, TValue>({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() =>
table.setPageIndex(table.getPageCount() - 1) table.setPageIndex(
table.getPageCount() - 1,
)
} }
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@ -594,6 +641,7 @@ export function DataTable<TData, TValue>({
)} )}
</div> </div>
</div> </div>
)}
</> </>
); );
} }

View File

@ -13,7 +13,58 @@ import {
InputGroupInput, InputGroupInput,
} from "@/components/ui/input-group" } from "@/components/ui/input-group"
const Combobox = ComboboxPrimitive.Root function isGroupedItems(
items: readonly unknown[]
): items is ReadonlyArray<{ items: readonly unknown[] }> {
return (
items.length > 0 &&
typeof items[0] === "object" &&
items[0] !== null &&
"items" in items[0]
)
}
function Combobox<Value, Multiple extends boolean | undefined = false>({
items,
multiple,
value,
isItemEqualToValue,
...props
}: ComboboxPrimitive.Root.Props<Value, Multiple>) {
// In multi-select mode, once an option is chosen it's represented as a
// chip, so it's removed from the dropdown list instead of staying there
// with a checkmark.
const filteredItems = React.useMemo(() => {
if (!multiple || !items) return items
const selected = value as unknown as Value[] | null | undefined
if (!Array.isArray(selected) || selected.length === 0) return items
const isEqual =
isItemEqualToValue ?? ((a: Value, b: Value) => Object.is(a, b))
const isNotSelected = (item: Value) =>
!selected.some((selectedValue) => isEqual(item, selectedValue))
if (isGroupedItems(items)) {
return items.map((group) => ({
...group,
items: group.items.filter((item) => isNotSelected(item as Value)),
}))
}
return (items as readonly Value[]).filter(isNotSelected)
}, [items, multiple, value, isItemEqualToValue])
return (
<ComboboxPrimitive.Root
items={filteredItems}
multiple={multiple}
value={value}
isItemEqualToValue={isItemEqualToValue}
{...props}
/>
)
}
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} /> return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />

View File

@ -0,0 +1,31 @@
import { LayoutGrid, LayoutList } from 'lucide-react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
export type ViewMode = 'table' | 'card';
type ViewToggleProps = {
value: ViewMode;
onChange: (value: ViewMode) => void;
};
export function ViewToggle({ value, onChange }: ViewToggleProps) {
return (
<ToggleGroup
type="single"
variant="outline"
value={value}
onValueChange={(next) => {
if (next) {
onChange(next as ViewMode);
}
}}
>
<ToggleGroupItem value="table" aria-label="Tampilan tabel">
<LayoutList className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="card" aria-label="Tampilan kartu">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@ -0,0 +1,225 @@
import { format } from 'date-fns';
import {
Clock,
ClipboardList,
Pencil,
Trash2,
Upload,
User,
} from 'lucide-react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { RowActions } from '@/components/row-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels } from '@/types/assignment';
import { deadlineTextClass, percentageOf } from './utils';
type CreateCardParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
handleSubmitClick: (assignment: Assignment) => void;
canUpdate: boolean;
canDelete: boolean;
canSubmit: boolean;
canViewSubmissions: boolean;
};
export function createAssignmentCard(params: CreateCardParams) {
const {
handleEdit,
handleDeleteClick,
handleSubmitClick,
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
} = params;
return function AssignmentCard(assignment: Assignment) {
const mySubmission = assignment.submissions?.[0];
const lecturerName =
assignment.course_class?.lecturer?.user?.profile?.full_name ??
null;
return (
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<p className="text-base leading-tight font-medium">
{assignment.title}
</p>
{assignment.course_class && (
<span className="text-xs text-muted-foreground">
{assignment.course_class.course?.code ?? ''}{' '}
{assignment.course_class.course?.name ?? ''}
</span>
)}
{lecturerName && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3 shrink-0" />
{lecturerName}
</span>
)}
</div>
<RowActions
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
actions={[
{
label:
mySubmission?.status === 'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: <Upload className="h-3.5 w-3.5" />,
show:
canSubmit && assignment.status === 'open',
onClick: () => handleSubmitClick(assignment),
},
{
label: 'Pengumpulan',
icon: (
<ClipboardList className="h-3.5 w-3.5" />
),
show: canViewSubmissions,
href: submissionsIndex.url(assignment.id),
},
{
label: 'Edit',
icon: <Pencil className="h-3.5 w-3.5" />,
show: canUpdate,
onClick: () => handleEdit(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-3.5 w-3.5 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(assignment),
},
]}
/>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
className="w-fit text-[10px] font-normal"
>
{AssignmentStatusLabels[assignment.status]}
</Badge>
{assignment.course_class?.academic_term && (
<Badge
variant="outline"
className="w-fit text-[10px] font-normal"
>
{formatAcademicTermLabel(
assignment.course_class.academic_term,
)}
</Badge>
)}
</div>
<span
className={`inline-flex items-center gap-1.5 text-xs ${deadlineTextClass(assignment.deadline)}`}
>
<Clock className="h-3.5 w-3.5 shrink-0" />
{format(new Date(assignment.deadline), 'd MMM yyyy, HH:mm')}
</span>
{assignment.attachment_url && assignment.attachment_name ? (
<div className="text-xs text-muted-foreground">
<AttachmentPreviewDialog
fileUrl={assignment.attachment_url}
fileName={assignment.attachment_name}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{canSubmit ? (
<Badge
variant={
mySubmission?.status === 'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status === 'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
) : (
<>
<Badge variant="secondary">
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{(
assignment.course_class
?.enrollments_count ?? 0
).toLocaleString('id-ID')}{' '}
Pengumpulan (
{percentageOf(
assignment.submissions_count,
assignment.course_class
?.enrollments_count ?? 0,
)}
%)
</Badge>
<Badge variant="outline">
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
Dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</Badge>
</>
)}
</div>
{assignment.description && (
<Accordion
type="single"
collapsible
className="-mx-6 -mb-6 border-t"
>
<AccordionItem
value="description"
className="border-b-0"
>
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
Deskripsi
</AccordionTrigger>
<AccordionContent className="px-6">
<p className="text-sm whitespace-pre-line text-foreground">
{assignment.description}
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</div>
);
};
}

View File

@ -0,0 +1,213 @@
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { ClipboardList, Pencil, Trash2, Upload } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels } from '@/types/assignment';
import { deadlineTextClass, percentageOf } from './utils';
export type { Assignment } from '@/types/assignment';
type CreateColumnsParams = {
handleEdit: (assignment: Assignment) => void;
handleDeleteClick: (assignment: Assignment) => void;
handleSubmitClick: (assignment: Assignment) => void;
canUpdate: boolean;
canDelete: boolean;
canSubmit: boolean;
canViewSubmissions: boolean;
};
export function createAssignmentColumns(
params: CreateColumnsParams,
): ColumnDef<Assignment>[] {
const {
handleEdit,
handleDeleteClick,
handleSubmitClick,
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
} = params;
const columns: ColumnDef<Assignment>[] = [
{
accessorKey: 'title',
header: () => <span>Judul</span>,
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('title') as string}
</span>
),
},
{
accessorKey: 'course_class.course.name',
header: () => <span>Kelas</span>,
cell: ({ row }) => {
const courseClass = row.original.course_class;
if (!courseClass) {
return '-';
}
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
},
},
{
accessorKey: 'deadline',
header: () => <span>Batas Waktu</span>,
cell: ({ row }) => {
const assignment = row.original;
return (
<span className={deadlineTextClass(assignment.deadline)}>
{format(
new Date(assignment.deadline),
'd MMM yyyy, HH:mm',
)}
</span>
);
},
},
{
accessorKey: 'status',
header: () => <span className="block text-center">Status</span>,
meta: {
className: 'text-center',
headerClassName: 'text-center',
},
cell: ({ row }) => {
const assignment = row.original;
return (
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
>
{AssignmentStatusLabels[assignment.status]}
</Badge>
);
},
},
{
id: 'submissions',
header: () => <span>Pengumpulan</span>,
cell: ({ row }) => {
const assignment = row.original;
if (canSubmit) {
const mySubmission = assignment.submissions?.[0];
return (
<Badge
variant={
mySubmission?.status === 'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status === 'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
);
}
const enrollmentsCount =
assignment.course_class?.enrollments_count ?? 0;
return (
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
<span>
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/ {enrollmentsCount.toLocaleString('id-ID')}{' '}
mengumpulkan (
{percentageOf(
assignment.submissions_count,
enrollmentsCount,
)}
%)
</span>
<span>
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</span>
</div>
);
},
},
];
columns.push({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[160px] text-center',
headerClassName: 'w-[160px] text-center',
},
cell: ({ row }) => {
const assignment = row.original;
const mySubmission = assignment.submissions?.[0];
return (
<div className="flex items-center justify-center">
<RowActions
actions={[
{
label:
mySubmission?.status === 'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: <Upload className="h-4 w-4" />,
show:
canSubmit && assignment.status === 'open',
onClick: () => handleSubmitClick(assignment),
},
{
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),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(assignment),
},
]}
/>
</div>
);
},
});
return columns;
}

View File

@ -1,37 +1,18 @@
import { Head, InfiniteScroll, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns'; import { Plus } from 'lucide-react';
import {
Clock,
ClipboardList,
Paperclip,
Pencil,
Plus,
Trash2,
Upload,
} from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog'; import { DataCards } from '@/components/data-cards';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { DateTimeField } from '@/components/datetime-field'; import { DateTimeField } from '@/components/datetime-field';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field'; import { FileUploadField } from '@/components/file-upload-field';
import type { import type { FilterOptionGroup } from '@/components/filter-dialog';
FilterField,
FilterOptionGroup,
} from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog'; import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog'; import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error'; import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { RowActions } from '@/components/row-actions';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
Combobox, Combobox,
ComboboxCollection, ComboboxCollection,
@ -53,6 +34,8 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import type { ViewMode } from '@/components/view-toggle';
import { ViewToggle } from '@/components/view-toggle';
import { usePermissions } from '@/hooks/use-permissions'; import { usePermissions } from '@/hooks/use-permissions';
import { useServerTable } from '@/hooks/use-server-table'; import { useServerTable } from '@/hooks/use-server-table';
import { import {
@ -62,10 +45,10 @@ import {
submit, submit,
update, update,
} from '@/routes/admin/academic-classes/assignments'; } from '@/routes/admin/academic-classes/assignments';
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { Assignment } from '@/types/assignment'; import type { Assignment } from '@/types/assignment';
import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment'; import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
import { createAssignmentCard } from './card';
import { createAssignmentColumns } from './columns';
type CourseClassOption = { type CourseClassOption = {
id: number; id: number;
@ -80,12 +63,6 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] }; type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = { type Props = {
assignments: { assignments: {
data: Assignment[]; data: Assignment[];
@ -95,11 +72,9 @@ type Props = {
total: number; total: number;
}; };
courseClasses: CourseClassOption[]; courseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number; highlight?: number;
filters: { filters: {
course_class_id?: string; course_class_id?: string;
academic_term_id?: string;
}; };
}; };
@ -107,29 +82,6 @@ function courseClassLabel(courseClass: CourseClassOption): string {
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`; return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
} }
function percentageOf(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0;
}
/** Turns red as the deadline passes, amber once it's within 3 days. */
function deadlineTextClass(deadline: string): string {
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
if (hoursLeft <= 0) {
return 'font-medium text-destructive';
}
if (hoursLeft <= 24) {
return 'text-destructive';
}
if (hoursLeft <= 72) {
return 'text-amber-600 dark:text-amber-500';
}
return 'text-muted-foreground';
}
function groupCourseClassesByDepartment( function groupCourseClassesByDepartment(
options: CourseClassOption[], options: CourseClassOption[],
): CourseClassGroup[] { ): CourseClassGroup[] {
@ -209,7 +161,6 @@ function CourseClassField({
export default function AssignmentIndex({ export default function AssignmentIndex({
assignments, assignments,
courseClasses, courseClasses,
academicTerms,
highlight, highlight,
filters, filters,
}: Props) { }: Props) {
@ -217,6 +168,7 @@ export default function AssignmentIndex({
const [editing, setEditing] = useState<Assignment | null>(null); const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null); const [deleting, setDeleting] = useState<Assignment | null>(null);
const [submitting, setSubmitting] = useState<Assignment | null>(null); const [submitting, setSubmitting] = useState<Assignment | null>(null);
const [view, setView] = useState<ViewMode>('table');
const { hasPermission } = usePermissions(); const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-assignments'); const canCreate = hasPermission('create-assignments');
const canUpdate = hasPermission('update-assignments'); const canUpdate = hasPermission('update-assignments');
@ -224,15 +176,7 @@ export default function AssignmentIndex({
const canViewSubmissions = hasPermission('view-assignment-submissions'); const canViewSubmissions = hasPermission('view-assignment-submissions');
const canSubmit = hasPermission('submit-assignments'); const canSubmit = hasPermission('submit-assignments');
const filterFields: FilterField[] = [ const filterFields = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{ {
key: 'course_class_id', key: 'course_class_id',
label: 'Kelas', label: 'Kelas',
@ -241,7 +185,7 @@ export default function AssignmentIndex({
}, },
]; ];
const pagination = { const pagination: PaginationState = {
current_page: assignments.current_page, current_page: assignments.current_page,
last_page: assignments.last_page, last_page: assignments.last_page,
per_page: assignments.per_page, per_page: assignments.per_page,
@ -255,19 +199,6 @@ export default function AssignmentIndex({
resetKeys: ['assignments'], resetKeys: ['assignments'],
}); });
function handleApplyFilters(newFilters: Record<string, string>) {
// Tanpa `academic_term_id` eksplisit, backend akan kembali ke
// periode aktif, jadi menghapus filter ini perlu dikirim eksplisit
// alih-alih hanya menghilangkan key-nya.
const clearedAcademicTerm =
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
applyFilters({
...newFilters,
...(clearedAcademicTerm ? { academic_term_id: '' } : {}),
});
}
function handleDelete() { function handleDelete() {
if (!deleting) { if (!deleting) {
return; return;
@ -278,6 +209,37 @@ export default function AssignmentIndex({
}); });
} }
const columns = createAssignmentColumns({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
handleSubmitClick: (assignment) => setSubmitting(assignment),
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
});
const renderAssignmentCard = createAssignmentCard({
handleEdit: (assignment) => setEditing(assignment),
handleDeleteClick: (assignment) => setDeleting(assignment),
handleSubmitClick: (assignment) => setSubmitting(assignment),
canUpdate,
canDelete,
canSubmit,
canViewSubmissions,
});
const toolbar = (
<>
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={applyFilters}
/>
<ViewToggle value={view} onChange={setView} />
</>
);
return ( return (
<> <>
<Head title="Tugas" /> <Head title="Tugas" />
@ -351,256 +313,29 @@ export default function AssignmentIndex({
assignment={submitting} assignment={submitting}
/> />
<div className="flex items-center gap-2"> {view === 'table' ? (
<Input <DataTable
placeholder="Cari judul tugas..." columns={columns}
value={search} data={assignments.data}
onChange={(event) => searchKey="title"
handleSearchChange(event.target.value) pagination={pagination}
} onSearchChange={handleSearchChange}
className="max-w-sm" searchValue={search}
infiniteScroll={{ propName: 'assignments' }}
toolbar={toolbar}
/> />
<FilterDialog
fields={filterFields}
activeFilters={filters}
onApply={handleApplyFilters}
/>
</div>
{assignments.data.length === 0 ? (
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Belum ada tugas.
</p>
) : ( ) : (
<InfiniteScroll <DataCards
data="assignments" data={assignments.data}
as="div" renderCard={renderAssignmentCard}
buffer={300} getRowId={(assignment) => assignment.id}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3" searchKey="title"
loading={() => ( pagination={pagination}
<p className="col-span-full py-4 text-center text-sm text-muted-foreground"> onSearchChange={handleSearchChange}
Memuat tugas... searchValue={search}
</p> infiniteScroll={{ propName: 'assignments' }}
)} toolbar={toolbar}
>
{assignments.data.map((assignment) => {
const mySubmission = assignment.submissions?.[0];
return (
<Card
key={assignment.id}
className={
highlight === assignment.id
? 'ring-2 ring-primary'
: undefined
}
>
<CardHeader className="flex flex-row items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-base leading-tight">
{assignment.title}
</CardTitle>
{assignment.course_class && (
<span className="text-xs text-muted-foreground">
{assignment.course_class
.course?.code ??
''}{' '}
{assignment.course_class
.course?.name ?? ''}
</span>
)}
</div>
<RowActions
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
actions={[
{
label:
mySubmission?.status ===
'submitted'
? 'Kumpulkan Ulang'
: 'Kumpulkan Tugas',
icon: (
<Upload className="h-3.5 w-3.5" />
),
show:
canSubmit &&
assignment.status ===
'open',
onClick: () =>
setSubmitting(
assignment,
),
},
{
label: 'Pengumpulan',
icon: (
<ClipboardList className="h-3.5 w-3.5" />
),
show: canViewSubmissions,
href: submissionsIndex.url(
assignment.id,
),
},
{
label: 'Edit',
icon: (
<Pencil className="h-3.5 w-3.5" />
),
show: canUpdate,
onClick: () =>
setEditing(assignment),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-3.5 w-3.5 text-destructive" />
),
show: canDelete,
onClick: () =>
setDeleting(assignment),
},
]}
/> />
</CardHeader>
<CardContent className="flex flex-col gap-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={
assignment.status === 'open'
? 'secondary'
: 'destructive'
}
className="w-fit text-[10px] font-normal"
>
{
AssignmentStatusLabels[
assignment.status
]
}
</Badge>
{assignment.course_class
?.academic_term && (
<Badge
variant="outline"
className="w-fit text-[10px] font-normal"
>
{formatAcademicTermLabel(
assignment.course_class
.academic_term,
)}
</Badge>
)}
</div>
<span
className={`inline-flex items-center gap-1.5 ${deadlineTextClass(assignment.deadline)}`}
>
<Clock className="h-3.5 w-3.5 shrink-0" />
{format(
new Date(assignment.deadline),
'd MMM yyyy, HH:mm',
)}
</span>
{assignment.attachment_url &&
assignment.attachment_name ? (
<div className="flex items-center gap-1.5">
<Paperclip className="h-3.5 w-3.5 shrink-0" />
<AttachmentPreviewDialog
fileUrl={
assignment.attachment_url
}
fileName={
assignment.attachment_name
}
/>
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{canSubmit ? (
<Badge
variant={
mySubmission?.status ===
'submitted'
? 'default'
: 'secondary'
}
>
{mySubmission?.status ===
'submitted'
? 'Sudah Mengumpulkan'
: 'Belum Mengumpulkan'}
</Badge>
) : (
<>
<Badge variant="secondary">
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{(
assignment
.course_class
?.enrollments_count ??
0
).toLocaleString(
'id-ID',
)}{' '}
Pengumpulan (
{percentageOf(
assignment.submissions_count,
assignment
.course_class
?.enrollments_count ??
0,
)}
%)
</Badge>
<Badge variant="outline">
{assignment.graded_submissions_count.toLocaleString(
'id-ID',
)}{' '}
/{' '}
{assignment.submissions_count.toLocaleString(
'id-ID',
)}{' '}
Dinilai (
{percentageOf(
assignment.graded_submissions_count,
assignment.submissions_count,
)}
%)
</Badge>
</>
)}
</div>
{assignment.description && (
<Accordion
type="single"
collapsible
className="-mx-6 -mb-6 border-t"
>
<AccordionItem
value="description"
className="border-b-0"
>
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
Deskripsi
</AccordionTrigger>
<AccordionContent className="px-6">
<p className="text-sm whitespace-pre-line text-foreground">
{
assignment.description
}
</p>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
);
})}
</InfiniteScroll>
)} )}
<DeleteConfirmDialog <DeleteConfirmDialog

View File

@ -94,15 +94,17 @@ export default function SubmissionIndex({ assignment, submissions }: Props) {
const columns: ColumnDef<Submission>[] = [ const columns: ColumnDef<Submission>[] = [
{ {
accessorKey: 'student.student_number', id: 'identity',
header: () => <span>NIM</span>, header: () => <span>NIM</span>,
cell: ({ row }) => row.original.student?.student_number ?? '-', cell: ({ row }) => (
}, <div className="flex flex-col">
{ <span>{row.original.student?.student_number ?? '-'}</span>
accessorKey: 'student.user.profile.full_name', <span className="text-sm text-muted-foreground">
header: () => <span>Nama Mahasiswa</span>, {row.original.student?.user?.profile?.full_name ??
cell: ({ row }) => '-'}
row.original.student?.user?.profile?.full_name ?? '-', </span>
</div>
),
}, },
{ {
accessorKey: 'status', accessorKey: 'status',

View File

@ -0,0 +1,22 @@
export function percentageOf(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0;
}
/** Turns red as the deadline passes, amber once it's within 3 days. */
export function deadlineTextClass(deadline: string): string {
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
if (hoursLeft <= 0) {
return 'font-medium text-destructive';
}
if (hoursLeft <= 24) {
return 'text-destructive';
}
if (hoursLeft <= 72) {
return 'text-amber-600 dark:text-amber-500';
}
return 'text-muted-foreground';
}

View File

@ -1,150 +0,0 @@
import { Head } from '@inertiajs/react';
import { format } from 'date-fns';
import { PageHeader } from '@/components/page-header';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatAcademicTermLabel } from '@/types/academic-term';
import type { AttendanceClassSummary } from '@/types/attendance';
import { AttendanceStatusLabels } from '@/types/attendance';
type Props = {
summaries: AttendanceClassSummary[];
};
function courseClassLabel(
courseClass: AttendanceClassSummary['course_class'],
): string {
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
}
function percentageOf(part: number, total: number): number {
return total > 0 ? Math.round((part / total) * 100) : 0;
}
export default function MyAttendance({ summaries }: Props) {
return (
<>
<Head title="Riwayat Kehadiran" />
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeader title="Riwayat Kehadiran" />
{summaries.length === 0 ? (
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
Belum ada kelas yang terdaftar.
</p>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{summaries.map((summary) => (
<Card key={summary.course_class.id}>
<CardHeader>
<CardTitle className="text-base leading-tight">
{courseClassLabel(summary.course_class)}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant={
percentageOf(
summary.present_count,
summary.total_count,
) >= 75
? 'secondary'
: 'destructive'
}
className="w-fit text-[10px] font-normal"
>
Hadir {summary.present_count} /{' '}
{summary.total_count} (
{percentageOf(
summary.present_count,
summary.total_count,
)}
%)
</Badge>
{summary.course_class.academic_term && (
<Badge
variant="outline"
className="w-fit text-[10px] font-normal"
>
{formatAcademicTermLabel(
summary.course_class
.academic_term,
)}
</Badge>
)}
</div>
{summary.records.length > 0 && (
<Accordion
type="single"
collapsible
className="-mx-6 -mb-6 border-t"
>
<AccordionItem
value="records"
className="border-b-0"
>
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
Detail Pertemuan
</AccordionTrigger>
<AccordionContent className="px-6">
<div className="flex flex-col divide-y">
{summary.records.map(
(record) => (
<div
key={`${record.course_class_id}-${record.meeting_number}`}
className="flex items-center justify-between gap-2 py-2 text-sm"
>
<span className="text-muted-foreground">
Pertemuan
ke-
{record.meeting_number ??
'-'}{' '}
&middot;{' '}
{format(
new Date(
record.date,
),
'd MMM yyyy',
)}
</span>
<Badge
variant={
record.status ===
'present'
? 'default'
: 'secondary'
}
>
{
AttendanceStatusLabels[
record
.status
]
}
</Badge>
</div>
),
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,133 @@
import { format } from 'date-fns';
import {
BookOpen,
CalendarDays,
FileIcon,
FileX,
Pencil,
Trash2,
User,
} from 'lucide-react';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { RowActions } from '@/components/row-actions';
import {
Attachment,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
} from '@/components/ui/attachment';
import { Badge } from '@/components/ui/badge';
import type { Material } from '@/types/material';
type CreateCardParams = {
handleEdit: (material: Material) => void;
handleDeleteClick: (material: Material) => void;
canUpdate: boolean;
canDelete: boolean;
};
export function createMaterialCard(params: CreateCardParams) {
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
return function MaterialCard(material: Material) {
const courseClass = material.course_class;
const lecturerName =
courseClass?.lecturer?.user?.profile?.full_name ?? null;
return (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BookOpen className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="font-medium break-words">
{material.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{courseClass
? `${courseClass.course?.code ?? ''} · ${courseClass.course?.name ?? ''}`
: '-'}
</p>
{lecturerName && (
<p className="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="h-3 w-3 shrink-0" />
{lecturerName}
</p>
)}
</div>
</div>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
show: canUpdate,
onClick: () => handleEdit(material),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
show: canDelete,
onClick: () => handleDeleteClick(material),
},
]}
/>
</div>
{material.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{material.description}
</p>
)}
{material.file_url && material.file_name ? (
<Attachment size="sm" className="w-full">
<AttachmentMedia>
<FileIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>
{material.file_name}
</AttachmentTitle>
<AttachmentDescription>
Lampiran materi
</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentPreviewDialog
fileUrl={material.file_url}
fileName={material.file_name}
variant="icon"
/>
</AttachmentActions>
</Attachment>
) : (
<div className="flex items-center gap-2 rounded-xl border border-dashed px-2.5 py-2 text-xs text-muted-foreground">
<FileX className="h-4 w-4 shrink-0" />
Tidak ada file dilampirkan
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
{material.meeting_number ? (
<Badge variant="secondary">
Pertemuan {material.meeting_number}
</Badge>
) : (
<span />
)}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5" />
{format(new Date(material.created_at), 'd MMM yyyy')}
</div>
</div>
</div>
);
};
}

View File

@ -61,41 +61,30 @@ export function createMaterialColumns(
return '-'; return '-';
} }
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`; return `${courseClass.course?.code ?? ''} · ${courseClass.course?.name ?? ''}`;
},
},
{
accessorKey: 'file_name',
header: () => <span>File</span>,
cell: ({ row }) => {
const material = row.original;
if (!material.file_url || !material.file_name) {
return <span className="text-muted-foreground">-</span>;
}
return (
<AttachmentPreviewDialog
fileUrl={material.file_url}
fileName={material.file_name}
/>
);
}, },
}, },
]; ];
if (canUpdate || canDelete) {
columns.push({ columns.push({
id: 'actions', id: 'actions',
header: () => <span className="block text-center">Aksi</span>, header: () => <span className="block text-center">Aksi</span>,
meta: { meta: {
className: 'w-[100px] text-center', className: 'w-[130px] text-center',
headerClassName: 'w-[100px] text-center', headerClassName: 'w-[130px] text-center',
}, },
cell: ({ row }) => { cell: ({ row }) => {
const material = row.original; const material = row.original;
return ( return (
<div className="flex items-center justify-center">
{material.file_url && material.file_name && (
<AttachmentPreviewDialog
fileUrl={material.file_url}
fileName={material.file_name}
variant="icon"
/>
)}
<RowActions <RowActions
actions={[ actions={[
{ {
@ -114,10 +103,10 @@ export function createMaterialColumns(
}, },
]} ]}
/> />
</div>
); );
}, },
}); });
}
return columns; return columns;
} }

Some files were not shown because too many files have changed in this diff Show More