Compare commits

..

No commits in common. "892d14c13ad9de1f85e7c9ca61596a375c41d759" and "88286fe596d9b3bc53d0b5468743a9f857571607" have entirely different histories.

132 changed files with 1865 additions and 3190 deletions

View File

@ -1,354 +0,0 @@
# 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.

View File

@ -1,27 +0,0 @@
<?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)',
};
}
}

View File

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

View File

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

View File

@ -34,6 +34,17 @@ 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
{
$this->abortUnlessLecturerOwnsClass($courseClass);

View File

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

View File

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

View File

@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
namespace App\Http\Controllers\Admin\AcademicClasses;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest;
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest;
use App\Models\Assignment;
use App\Models\Submission;
use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
use App\Services\Admin\AcademicClasses\SubmissionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -20,7 +20,7 @@ public function __construct(
public function index(Assignment $assignment): Response
{
abort_unless(request()->user()->canManageAssignment($assignment), 403);
$this->abortUnlessLecturerOwnsAssignment($assignment);
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
@ -45,4 +45,17 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment)
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +0,0 @@
<?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,6 +1,6 @@
<?php
namespace App\Http\Requests\Admin\Manage\CourseClass;
namespace App\Http\Requests\Admin\Manage;
use App\Models\AcademicTerm;
use Illuminate\Foundation\Http\FormRequest;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,21 +16,6 @@ class AcademicTerm extends Model
{
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
{
return $this->hasMany(TuitionInvoice::class);

View File

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

View File

@ -22,11 +22,6 @@ protected function casts(): array
];
}
public function academicTerm(): BelongsTo
{
return $this->belongsTo(AcademicTerm::class);
}
public function course(): BelongsTo
{
return $this->belongsTo(Course::class);
@ -37,14 +32,9 @@ public function lecturer(): BelongsTo
return $this->belongsTo(Lecturer::class);
}
public function assignments(): HasMany
public function academicTerm(): BelongsTo
{
return $this->hasMany(Assignment::class);
}
public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
return $this->belongsTo(AcademicTerm::class);
}
public function enrollments(): HasMany
@ -57,13 +47,23 @@ public function materials(): HasMany
return $this->hasMany(Material::class);
}
public function registrations(): HasMany
public function assignments(): HasMany
{
return $this->hasMany(CourseRegistration::class);
return $this->hasMany(Assignment::class);
}
public function schedules(): HasMany
{
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,6 +12,11 @@ class CourseRegistration extends Model
{
use HasFactory;
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function academicTerm(): BelongsTo
{
return $this->belongsTo(AcademicTerm::class);
@ -22,11 +27,6 @@ public function courseClass(): BelongsTo
return $this->belongsTo(CourseClass::class);
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function submission(): BelongsTo
{
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
app/Models/Mahasiswa.php Normal file
View File

@ -0,0 +1,10 @@
<?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 creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

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

View File

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

View File

@ -2,7 +2,6 @@
namespace App\Models;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@ -39,11 +38,6 @@ protected function fullName(): Attribute
return Attribute::get(fn () => $this->profile?->full_name ?? $this->username);
}
public function lecturer(): HasOne
{
return $this->hasOne(Lecturer::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
@ -54,34 +48,9 @@ public function student(): HasOne
return $this->hasOne(Student::class);
}
public function courseRegistrationSubmissionLogs(): HasMany
public function lecturer(): HasOne
{
return $this->hasMany(CourseRegistrationSubmissionLog::class, 'actor_id');
}
public function createdAnnouncements(): HasMany
{
return $this->hasMany(Announcement::class, 'created_by');
}
public function createdNotifications(): HasMany
{
return $this->hasMany(Notification::class, 'created_by');
}
public function feedbacks(): HasMany
{
return $this->hasMany(Feedback::class);
}
public function handledFeedbacks(): HasMany
{
return $this->hasMany(Feedback::class, 'handled_by');
}
public function letterRequests(): HasMany
{
return $this->hasMany(LetterRequest::class);
return $this->hasOne(Lecturer::class);
}
public function notifications(): HasMany
@ -89,85 +58,45 @@ public function notifications(): HasMany
return $this->hasMany(Notification::class);
}
public function processedLetterRequests(): HasMany
public function feedbacks(): 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');
}
/**
* A dosen may only manage (update/delete) materials of classes they lecture.
*/
public function canManageMaterial(Material $material): bool
{
if ($this->hasRole(UserRole::Dosen->value)) {
return $material->courseClass->lecturer_id === $this->lecturer?->id;
}
return true;
}
/**
* A dosen may only manage assignments (update/delete) and their submissions
* (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;
return $this->hasMany(Feedback::class);
}
public function isAdvisorOf(Student $student): bool
{
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
}
public function isDepartmentLeaderOf(Student $student): bool
public function isKaprodiOf(Student $student): bool
{
return $this->hasRole(UserRole::Kaprodi->value) && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
return $this->hasRole('kaprodi') && $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.
* Gates page access: the student's academic advisor, the kaprodi 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);
if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) {
return $this->isAdvisorOf($student) || $this->isKaprodiOf($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.
* Only the student's academic advisor may approve/reject; the kaprodi
* signs but does not decide. Other staff roles retain override access.
*/
public function canReviewCourseRegistrationOf(Student $student): bool
{
if ($this->hasRole(UserRole::Dosen->value)) {
if ($this->hasRole('dosen')) {
return $this->isAdvisorOf($student);
}
if ($this->hasRole(UserRole::Kaprodi->value)) {
if ($this->hasRole('kaprodi')) {
return false;
}

View File

@ -1,52 +1,42 @@
<?php
namespace App\Services\Admin\AcademicClasses\Assignment;
namespace App\Services\Admin\AcademicClasses;
use App\Enums\AssignmentStatus;
use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\Assignment;
use App\Models\CourseRegistration;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
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
{
return Assignment::query()
->select(['id', 'course_class_id', 'title', 'description', 'deadline', 'status'])
->withCount([
'submissions',
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
])
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
->with([
'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) {
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
->where('student_id', $user->student?->id);
}]))
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->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($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) {
->when($user->hasRole('dosen'), 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) {
$q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
});
}))
->latest()
->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
->paginate($perPage);
}
public function create(array $data, ?UploadedFile $file): Assignment
@ -63,33 +53,17 @@ public function create(array $data, ?UploadedFile $file): Assignment
$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;
}
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
{
$assignment->update([
'course_class_id' => $data['course_class_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'deadline' => $data['deadline'],
'status' => $data['status'] ?? $assignment->status,
]);
$assignment->course_class_id = $data['course_class_id'];
$assignment->title = $data['title'];
$assignment->description = $data['description'] ?? null;
$assignment->deadline = $data['deadline'];
$assignment->status = $data['status'] ?? $assignment->status;
$assignment->update();
if ($file) {
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');

View File

@ -5,8 +5,10 @@
use App\Enums\AttendanceStatus;
use App\Models\Attendance;
use App\Models\CourseClass;
use App\Models\Student;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;
class AttendanceService
{
@ -111,4 +113,35 @@ public function deleteSession(int $courseClassId, int $meetingNumber): void
->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,39 +3,29 @@
namespace App\Services\Admin\AcademicClasses;
use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\CourseRegistration;
use App\Models\Material;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
class MaterialService
{
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): LengthAwarePaginator
{
return Material::query()
->with([
'courseClass.course:id,code,name',
'courseClass.lecturer.user.profile',
])
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number'])
->with('courseClass.course:id,code,name')
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
->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($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) {
->when($user->hasRole('dosen'), 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) {
$q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
});
}))
->latest()
->paginate($perPage, ['id', 'course_class_id', 'title', 'description', 'meeting_number', 'created_at']);
->paginate($perPage);
}
public function create(array $data, ?UploadedFile $file): Material
@ -51,32 +41,16 @@ public function create(array $data, ?UploadedFile $file): Material
$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;
}
public function update(Material $material, array $data, ?UploadedFile $file): Material
{
$material->update([
'course_class_id' => $data['course_class_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'meeting_number' => $data['meeting_number'] ?? null,
]);
$material->course_class_id = $data['course_class_id'];
$material->title = $data['title'];
$material->description = $data['description'] ?? null;
$material->meeting_number = $data['meeting_number'] ?? null;
$material->update();
if ($file) {
$material->addMedia($file)->toMediaCollection('materials');

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\AcademicClasses;
use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\CourseClass;
use App\Models\Schedule;
use App\Models\User;
@ -14,6 +13,7 @@ class ScheduleService
public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection
{
return Schedule::query()
->select(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link'])
->with([
'courseClass:id,course_id,lecturer_id,academic_term_id,method',
'courseClass.course:id,code,name,department_id',
@ -26,14 +26,39 @@ public function all(User $user, ?int $academicTermId = null, ?int $departmentId
$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($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber)))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
$q->where('student_id', $user->student?->id)
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
}))
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
})
->orderBy('start_time')
->get(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link']);
->get();
}
/**
* 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
@ -50,14 +75,13 @@ public function create(array $data): Schedule
public function update(Schedule $schedule, array $data): Schedule
{
$schedule->update([
'course_class_id' => $data['course_class_id'],
'day_of_week' => $data['day_of_week'],
'start_time' => $data['start_time'],
'end_time' => $data['end_time'],
'room' => $data['room'] ?? null,
'online_link' => $data['online_link'] ?? null,
]);
$schedule->course_class_id = $data['course_class_id'];
$schedule->day_of_week = $data['day_of_week'];
$schedule->start_time = $data['start_time'];
$schedule->end_time = $data['end_time'];
$schedule->room = $data['room'] ?? null;
$schedule->online_link = $data['online_link'] ?? null;
$schedule->update();
return $schedule;
}
@ -66,28 +90,4 @@ public function delete(Schedule $schedule): bool
{
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

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

View File

@ -41,10 +41,18 @@ public function __construct()
$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
{
return Feedback::query()
->when(! $user->can('update-feedback-status'), fn ($q) => $q->where('user_id', $user->id))
->where('user_id', $user->id)
->with(['user.profile', 'handler.profile'])
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
->when($type, fn ($q) => $q->where('type', $type))
@ -65,20 +73,14 @@ public function create(User $user, array $data): Feedback
public function update(Feedback $feedback, array $data): Feedback
{
$feedback->update([
'type' => $data['type'],
'subject' => $data['subject'],
'message' => $this->sanitizer->sanitize($data['message']),
]);
$feedback->type = $data['type'];
$feedback->subject = $data['subject'];
$feedback->message = $this->sanitizer->sanitize($data['message']);
$feedback->update();
return $feedback;
}
public function delete(Feedback $feedback): bool
{
return $feedback->delete();
}
public function updateStatus(Feedback $feedback, string $status): Feedback
{
$feedback->update([
@ -89,26 +91,8 @@ public function updateStatus(Feedback $feedback, string $status): Feedback
return $feedback;
}
public function reply(Feedback $feedback, string $reply): Feedback
public function delete(Feedback $feedback): bool
{
$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();
return $feedback->delete();
}
}

View File

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

View File

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

View File

@ -1,20 +1,36 @@
<?php
namespace App\Services\Admin\Manage\CourseClass;
namespace App\Services\Admin\Manage;
use App\Enums\UserRole;
use App\Models\CourseClass;
use App\Models\User;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class CourseClassService
{
public function __construct(
private readonly AcademicTermService $academicTermService,
) {}
/**
* When $user is a dosen, only their own assigned classes are returned.
* 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
{
@ -24,9 +40,7 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
->join('courses', 'courses.id', '=', 'course_classes.course_id')
->withCount('enrollments')
->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->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($search, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
->when($method, fn ($q) => $q->where('course_classes.method', $method))
->orderByDesc('academic_terms.start_date')
@ -51,12 +65,11 @@ public function createMany(array $data): Collection
public function update(CourseClass $courseClass, array $data): CourseClass
{
$courseClass->update([
'course_id' => $data['course_id'],
'lecturer_id' => $data['lecturer_id'],
'academic_term_id' => $data['academic_term_id'],
'method' => $data['method'] ?? null,
]);
$courseClass->course_id = $data['course_id'];
$courseClass->lecturer_id = $data['lecturer_id'];
$courseClass->academic_term_id = $data['academic_term_id'];
$courseClass->method = $data['method'] ?? null;
$courseClass->update();
return $courseClass;
}
@ -108,29 +121,4 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
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,7 +4,6 @@
use App\Enums\RegistrationStatus;
use App\Enums\StudentStatus;
use App\Enums\UserRole;
use App\Models\AcademicTerm;
use App\Models\ClassEnrollment;
use App\Models\Course;
@ -13,7 +12,6 @@
use App\Models\CourseRegistrationSubmission;
use App\Models\Department;
use App\Models\Student;
use App\Models\User;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
@ -26,13 +24,24 @@ public function __construct(
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
{
$isDepartmentLeader = $user->hasRole(UserRole::Kaprodi->value);
$advisorLecturerId = ! $isDepartmentLeader && $user->hasRole(UserRole::Dosen->value) ? $user->lecturer?->id : null;
$ledDepartmentIds = $isDepartmentLeader ? $user->ledDepartmentIds() : null;
return Department::query()
->select('id', 'name')
->orderBy('name')
->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()
->select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id'])
->join('departments', 'departments.id', '=', 'students.department_id')
->join('users', 'users.id', '=', 'students.user_id')
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
@ -45,7 +54,7 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i
->with(['user.profile', 'department:id,name'])
->orderBy('departments.name')
->orderBy('user_profiles.full_name')
->paginate($perPage, ['students.id', 'students.user_id', 'students.student_number', 'students.department_id']);
->paginate($perPage);
return $paginator->through(fn (Student $student) => [
'student_id' => $student->id,
@ -210,11 +219,11 @@ public function saveRegistrations(
});
}
public function signAsDepartmentLeader(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
{
$this->assertSignable($submission);
$submission->addMedia($signature)->toMediaCollection('department_leader_signature');
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature');
return $submission;
}
@ -237,7 +246,7 @@ private function assertSignable(CourseRegistrationSubmission $submission): void
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
{
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
abort_if($submission->department_leader_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
abort_if($submission->kaprodi_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.');
return DB::transaction(function () use ($submission) {
@ -290,14 +299,4 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
return $submission;
});
}
/**
* @return Collection<int, Department>
*/
public function departmentSummary(): Collection
{
return Department::query()
->orderBy('name')
->get(['id', 'name']);
}
}

View File

@ -9,14 +9,30 @@
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
{
return AcademicTerm::query()
->when($search, fn ($q) => $q->where('academic_year', 'like', "%{$search}%"))
->when($semester, fn ($q) => $q->where('semester', $semester))
->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
->select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])
->when($search, fn($q) => $q->where('academic_year', 'like', "%{$search}%"))
->when($semester, fn($q) => $q->where('semester', $semester))
->when($isActive !== null, fn($q) => $q->where('is_active', $isActive))
->latest()
->paginate($perPage, ['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
->paginate($perPage);
}
public function create(array $data): AcademicTerm
@ -29,14 +45,13 @@ public function create(array $data): AcademicTerm
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
{
$academicTerm->update([
'academic_year' => $data['academic_year'],
'semester' => $data['semester'],
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'is_active' => $data['is_active'],
'open_semesters' => $this->normalizeOpenSemesters($data['open_semesters'] ?? []),
]);
$academicTerm->academic_year = $data['academic_year'];
$academicTerm->semester = $data['semester'];
$academicTerm->start_date = $data['start_date'];
$academicTerm->end_date = $data['end_date'];
$academicTerm->is_active = $data['is_active'];
$academicTerm->open_semesters = $this->normalizeOpenSemesters($data['open_semesters'] ?? []);
$academicTerm->update();
return $academicTerm;
}
@ -57,21 +72,6 @@ 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
* @return array<int, int>

View File

@ -2,28 +2,52 @@
namespace App\Services\Admin\Master;
use App\Enums\UserRole;
use App\Models\Course;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class CourseService
{
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
public function getAllForSelect(): Collection
{
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')
->with('department:id,name')
->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($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
->when($user->hasRole('mahasiswa'), 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') ?? []))
->orderBy('departments.name')
->orderBy('courses.semester_number')
->orderBy('courses.name')
->paginate($perPage, ['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number']);
->paginate($perPage);
}
public function create(array $data): Course
@ -33,13 +57,12 @@ public function create(array $data): Course
public function update(Course $course, array $data): Course
{
$course->update([
'code' => $data['code'],
'name' => $data['name'],
'credits' => $data['credits'],
'department_id' => $data['department_id'],
'semester_number' => $data['semester_number'] ?? null,
]);
$course->code = $data['code'];
$course->name = $data['name'];
$course->credits = $data['credits'];
$course->department_id = $data['department_id'];
$course->semester_number = $data['semester_number'] ?? null;
$course->update();
return $course;
}
@ -48,25 +71,4 @@ public function delete(Course $course): bool
{
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,7 +2,6 @@
namespace App\Services\Admin\Master;
use App\Enums\UserRole;
use App\Models\Department;
use App\Models\DepartmentLeadership;
use App\Models\Lecturer;
@ -11,17 +10,19 @@
class DepartmentService
{
public function getAllForSelect(): Collection
{
return Department::select(['id', 'code', 'name', 'degree_level'])->get();
}
public function paginated(int $perPage = 25, string $search = ''): LengthAwarePaginator
{
return Department::query()
->with([
'currentLeader.lecturer.user.profile',
'leaderships' => fn ($q) => $q->orderByRaw('ended_at IS NULL DESC')->orderByDesc('started_at'),
'leaderships.lecturer.user.profile',
])
->select(['id', 'code', 'name', 'degree_level'])
->with(['currentLeader.lecturer.user.profile'])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
->latest()
->paginate($perPage, ['id', 'code', 'name', 'degree_level']);
->paginate($perPage);
}
public function create(array $data): Department
@ -39,27 +40,16 @@ public function create(array $data): Department
public function update(Department $department, array $data): Department
{
$department->update([
'code' => $data['code'],
'name' => $data['name'],
'degree_level' => $data['degree_level'] ?? null,
]);
$department->code = $data['code'];
$department->name = $data['name'];
$department->degree_level = $data['degree_level'] ?? null;
$department->update();
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
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
{
$currentLeader = DepartmentLeadership::query()
@ -100,8 +90,8 @@ private function grantKaprodiRole(int $lecturerId): void
{
$user = Lecturer::find($lecturerId)?->user;
if ($user && ! $user->hasRole(UserRole::Kaprodi->value)) {
$user->assignRole(UserRole::Kaprodi->value);
if ($user && ! $user->hasRole('kaprodi')) {
$user->assignRole('kaprodi');
}
}
@ -113,7 +103,12 @@ private function revokeKaprodiRoleIfNoLongerLeading(int $lecturerId): void
->exists();
if (! $stillLeadsAnyDepartment) {
Lecturer::find($lecturerId)?->user?->removeRole(UserRole::Kaprodi->value);
Lecturer::find($lecturerId)?->user?->removeRole('kaprodi');
}
}
public function delete(Department $department): bool
{
return $department->delete();
}
}

View File

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

View File

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

View File

@ -2,28 +2,21 @@
namespace App\Services\Admin\Users;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class AdministratorService
{
/**
* @var list<UserRole>
*/
private const ROLES = [UserRole::StaffAdmin, UserRole::StaffKeuangan];
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null): LengthAwarePaginator
{
return User::with([
return User::select(['id', 'username', 'email', 'is_active'])
->with([
'profile:id,user_id,full_name,phone_number,gender',
'roles:id,name',
])
->whereHas('roles', fn ($q) => $q->whereIn('name', array_column(self::ROLES, 'value')))
->whereHas('roles', fn ($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan']))
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
$query->where('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
@ -31,7 +24,7 @@ public function paginated(int $perPage = 25, string $search = '', ?string $gende
}))
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
->latest()
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
->paginate($perPage);
}
public function create(array $data): User
@ -100,12 +93,4 @@ public function updateUserStatus(User $user, bool $isActive): void
{
$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,7 +2,6 @@
namespace App\Services\Admin\Users;
use App\Enums\UserRole;
use App\Models\Lecturer;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -13,11 +12,51 @@
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
{
return $this->filteredQuery($search, $gender, $departmentId)
->latest()
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
->paginate($perPage);
}
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
@ -29,7 +68,7 @@ public function create(array $data): User
'password' => Hash::make(config('app.default_password')),
]);
$user->assignRole(UserRole::Dosen->value);
$user->assignRole('dosen');
$user->profile()->create([
'full_name' => $data['full_name'],
@ -97,42 +136,4 @@ public function updateUserStatus(User $user, bool $isActive): void
{
$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,8 +2,6 @@
namespace App\Services\Admin\Users;
use App\Enums\StudentStatus;
use App\Enums\UserRole;
use App\Models\Student;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -14,11 +12,67 @@
class StudentService
{
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
public function getAllForSelect(?string $status = null): Collection
{
return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
return Student::select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester'])
->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()
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
->paginate($perPage);
}
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
@ -30,7 +84,7 @@ public function create(array $data): User
'password' => Hash::make(config('app.default_password')),
]);
$user->assignRole(UserRole::Mahasiswa->value);
$user->assignRole('mahasiswa');
$user->profile()->create([
'full_name' => $data['full_name'],
@ -47,7 +101,7 @@ public function create(array $data): User
'enrollment_year' => $data['enrollment_year'],
'current_semester' => $data['current_semester'],
'academic_advisor_id' => $data['academic_advisor_id'],
'status' => StudentStatus::Active,
'status' => 'active',
]);
return $user;
@ -108,62 +162,4 @@ public function updateUserStatus(User $user, bool $isActive): void
{
$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',
'submit-assignments',
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
'view-attendances', 'create-attendances', 'delete-attendances',
'view-attendances', 'create-attendances', 'delete-attendances', 'view-own-attendances',
];
public const MANAGE = [
@ -50,7 +50,7 @@ class PermissionCatalog
];
public const FEEDBACK = [
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'reply-feedback',
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status',
];
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,228 +0,0 @@
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,7 +12,6 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { InfiniteScroll } from '@inertiajs/react';
import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table';
import {
flexRender,
@ -72,15 +71,6 @@ interface DataTableProps<TData, TValue> {
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;
};
}
const DragHandleContext = React.createContext<{
@ -139,7 +129,7 @@ function SortableTableRow({
);
}
export function useDebounce(callback: (value: string) => void, delay: number) {
function useDebounce(callback: (value: string) => void, delay: number) {
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
return React.useCallback(
@ -173,10 +163,7 @@ export function DataTable<TData, TValue>({
onPerPageChange,
onSearchChange,
searchValue,
infiniteScroll,
}: DataTableProps<TData, TValue>) {
const tbodyRef = React.useRef<HTMLTableSectionElement>(null);
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
if (!defaultExpanded || !data.length) {
return {};
@ -196,9 +183,7 @@ export function DataTable<TData, TValue>({
setLocalSearch(searchValue ?? '');
}, [searchValue]);
const isInfiniteScroll = !!infiniteScroll;
const hasServerPager = !isInfiniteScroll && !!pagination && !!onPageChange;
const hasServerSearch = !!onSearchChange;
const isServerMode = !!pagination && !!onPageChange;
const isSortable = !!onReorder && !!getRowId;
const handleSearchDebounced = useDebounce(
@ -215,7 +200,7 @@ export function DataTable<TData, TValue>({
header: () => <span className="block text-center">No</span>,
cell: ({ row }) => (
<span className="block text-center">
{hasServerPager
{isServerMode
? (currentPage - 1) * (pagination?.per_page ?? 25) +
row.index +
1
@ -290,14 +275,14 @@ export function DataTable<TData, TValue>({
function handleSearchChange(value: string) {
setLocalSearch(value);
if (hasServerSearch) {
if (isServerMode) {
handleSearchDebounced(value);
}
}
return (
<>
{(searchKey || toolbar || hasServerPager || isInfiniteScroll) && (
{(searchKey || toolbar || isServerMode) && (
<div className="flex items-center gap-2">
{searchKey && (
<Input
@ -311,7 +296,7 @@ export function DataTable<TData, TValue>({
)}
{toolbar}
<div className="ml-auto flex items-center gap-2">
{hasServerPager && onPerPageChange && (
{isServerMode && onPerPageChange && (
<Select
value={String(pagination?.per_page ?? 25)}
onValueChange={(value) =>
@ -364,7 +349,7 @@ export function DataTable<TData, TValue>({
</TableRow>
))}
</TableHeader>
<TableBody ref={tbodyRef}>
<TableBody>
{table.getRowModel().rows?.length ? (
isSortable ? (
<DndContext
@ -422,7 +407,8 @@ export function DataTable<TData, TValue>({
</DndContext>
) : (
(() => {
let previousGroup: string | null = null;
let previousGroup: string | null =
null;
return table
.getRowModel()
@ -459,7 +445,8 @@ export function DataTable<TData, TValue>({
>
{row
.getVisibleCells()
.map((cell) => (
.map(
(cell) => (
<TableCell
key={
cell.id
@ -484,7 +471,8 @@ export function DataTable<TData, TValue>({
cell.getContext(),
)}
</TableCell>
))}
),
)}
</TableRow>
{renderSubRow &&
row.getIsExpanded() && (
@ -524,43 +512,14 @@ export function DataTable<TData, TValue>({
</CardContent>
</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">
<span className="text-sm text-muted-foreground">
{hasServerPager
{isServerMode
? `Halaman ${currentPage} dari ${totalPages}`
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
</span>
<div className="flex items-center gap-1">
{hasServerPager ? (
{isServerMode ? (
<>
<Button
variant="outline"
@ -573,9 +532,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="sm"
onClick={() =>
onPageChange(currentPage - 1)
}
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
<ChevronLeft className="h-4 w-4" />
@ -583,9 +540,7 @@ export function DataTable<TData, TValue>({
<Button
variant="outline"
size="sm"
onClick={() =>
onPageChange(currentPage + 1)
}
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
<ChevronRight className="h-4 w-4" />
@ -629,9 +584,7 @@ export function DataTable<TData, TValue>({
variant="outline"
size="sm"
onClick={() =>
table.setPageIndex(
table.getPageCount() - 1,
)
table.setPageIndex(table.getPageCount() - 1)
}
disabled={!table.getCanNextPage()}
>
@ -641,7 +594,6 @@ export function DataTable<TData, TValue>({
)}
</div>
</div>
)}
</>
);
}

View File

@ -13,58 +13,7 @@ import {
InputGroupInput,
} from "@/components/ui/input-group"
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}
/>
)
}
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />

View File

@ -1,31 +0,0 @@
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

@ -1,225 +0,0 @@
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

@ -1,213 +0,0 @@
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,18 +1,37 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { Head, InfiniteScroll, router } from '@inertiajs/react';
import { format } from 'date-fns';
import {
Clock,
ClipboardList,
Paperclip,
Pencil,
Plus,
Trash2,
Upload,
} from 'lucide-react';
import { useState } from 'react';
import { DataCards } from '@/components/data-cards';
import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table';
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
import { DateTimeField } from '@/components/datetime-field';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FileUploadField } from '@/components/file-upload-field';
import type { FilterOptionGroup } from '@/components/filter-dialog';
import type {
FilterField,
FilterOptionGroup,
} from '@/components/filter-dialog';
import { FilterDialog } from '@/components/filter-dialog';
import { FormDialog } from '@/components/form-dialog';
import InputError from '@/components/input-error';
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxCollection,
@ -34,8 +53,6 @@ import {
SelectValue,
} from '@/components/ui/select';
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 { useServerTable } from '@/hooks/use-server-table';
import {
@ -45,10 +62,10 @@ import {
submit,
update,
} 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 { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
import { createAssignmentCard } from './card';
import { createAssignmentColumns } from './columns';
import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment';
type CourseClassOption = {
id: number;
@ -63,6 +80,12 @@ type CourseClassOption = {
type CourseClassGroup = { value: string; items: CourseClassOption[] };
type AcademicTermOption = {
id: number;
academic_year: string;
semester: string;
};
type Props = {
assignments: {
data: Assignment[];
@ -72,9 +95,11 @@ type Props = {
total: number;
};
courseClasses: CourseClassOption[];
academicTerms: AcademicTermOption[];
highlight?: number;
filters: {
course_class_id?: string;
academic_term_id?: string;
};
};
@ -82,6 +107,29 @@ function courseClassLabel(courseClass: CourseClassOption): string {
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(
options: CourseClassOption[],
): CourseClassGroup[] {
@ -161,6 +209,7 @@ function CourseClassField({
export default function AssignmentIndex({
assignments,
courseClasses,
academicTerms,
highlight,
filters,
}: Props) {
@ -168,7 +217,6 @@ export default function AssignmentIndex({
const [editing, setEditing] = useState<Assignment | null>(null);
const [deleting, setDeleting] = useState<Assignment | null>(null);
const [submitting, setSubmitting] = useState<Assignment | null>(null);
const [view, setView] = useState<ViewMode>('table');
const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-assignments');
const canUpdate = hasPermission('update-assignments');
@ -176,7 +224,15 @@ export default function AssignmentIndex({
const canViewSubmissions = hasPermission('view-assignment-submissions');
const canSubmit = hasPermission('submit-assignments');
const filterFields = [
const filterFields: FilterField[] = [
{
key: 'academic_term_id',
label: 'Periode Akademik',
options: academicTerms.map((term) => ({
value: String(term.id),
label: formatAcademicTermLabel(term),
})),
},
{
key: 'course_class_id',
label: 'Kelas',
@ -185,7 +241,7 @@ export default function AssignmentIndex({
},
];
const pagination: PaginationState = {
const pagination = {
current_page: assignments.current_page,
last_page: assignments.last_page,
per_page: assignments.per_page,
@ -199,6 +255,19 @@ export default function AssignmentIndex({
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() {
if (!deleting) {
return;
@ -209,37 +278,6 @@ 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 (
<>
<Head title="Tugas" />
@ -313,29 +351,256 @@ export default function AssignmentIndex({
assignment={submitting}
/>
{view === 'table' ? (
<DataTable
columns={columns}
data={assignments.data}
searchKey="title"
pagination={pagination}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'assignments' }}
toolbar={toolbar}
<div className="flex items-center gap-2">
<Input
placeholder="Cari judul tugas..."
value={search}
onChange={(event) =>
handleSearchChange(event.target.value)
}
className="max-w-sm"
/>
<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>
) : (
<DataCards
data={assignments.data}
renderCard={renderAssignmentCard}
getRowId={(assignment) => assignment.id}
searchKey="title"
pagination={pagination}
onSearchChange={handleSearchChange}
searchValue={search}
infiniteScroll={{ propName: 'assignments' }}
toolbar={toolbar}
<InfiniteScroll
data="assignments"
as="div"
buffer={300}
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
loading={() => (
<p className="col-span-full py-4 text-center text-sm text-muted-foreground">
Memuat tugas...
</p>
)}
>
{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

View File

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

View File

@ -1,22 +0,0 @@
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

@ -0,0 +1,150 @@
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

@ -1,133 +0,0 @@
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,30 +61,41 @@ export function createMaterialColumns(
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({
id: 'actions',
header: () => <span className="block text-center">Aksi</span>,
meta: {
className: 'w-[130px] text-center',
headerClassName: 'w-[130px] text-center',
className: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center',
},
cell: ({ row }) => {
const material = row.original;
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
actions={[
{
@ -103,10 +114,10 @@ export function createMaterialColumns(
},
]}
/>
</div>
);
},
});
}
return columns;
}

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