Compare commits
37 Commits
88286fe596
...
892d14c13a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892d14c13a | ||
|
|
8fb596fb32 | ||
|
|
56d607a131 | ||
|
|
35a64c2045 | ||
|
|
33f354ad94 | ||
|
|
139875ce31 | ||
|
|
facf7ab177 | ||
|
|
19397d3b5b | ||
|
|
acee293b36 | ||
|
|
6ec257a1cc | ||
|
|
ceeb1e325d | ||
|
|
7ea4fb067d | ||
|
|
d411085400 | ||
|
|
66bacaf0e1 | ||
|
|
609828a2e2 | ||
|
|
573d834905 | ||
|
|
024dc4dd5a | ||
|
|
88319dac15 | ||
|
|
80435b6cfa | ||
|
|
f6da193473 | ||
|
|
32c04b91dd | ||
|
|
900c27041a | ||
|
|
37ce3c15ea | ||
|
|
51e943572a | ||
|
|
80997b97b4 | ||
|
|
db56ec94ea | ||
|
|
aa50487bfc | ||
|
|
1c901e9103 | ||
|
|
2b458e26fa | ||
|
|
f00fab9520 | ||
|
|
8055d4ec5d | ||
|
|
f69ed0bc15 | ||
|
|
aa47ec494e | ||
|
|
8f9eb5f77a | ||
|
|
ad5fe33ec6 | ||
|
|
3bec716dab | ||
|
|
9f717a3441 |
354
BEST_PRACTICE.md
Normal file
354
BEST_PRACTICE.md
Normal file
@ -0,0 +1,354 @@
|
||||
# Best Practice Laravel
|
||||
|
||||
Panduan konvensi kode untuk **semua project** dengan tech stack: **Laravel 13 (PHP 8.3)**, **Inertia + React + TypeScript**, **Laravel Wayfinder**, **Spatie Permission**, **Pest**, **Larastan**, **Laravel Pint**.
|
||||
|
||||
Dokumen ini bersifat reusable antar-project — jangan isi dengan nama domain/fitur spesifik satu project saja. Kalau sebuah project punya pengecualian dari aturan di sini, catat pengecualiannya di `CLAUDE.md`/README project tersebut, bukan mengubah dokumen ini.
|
||||
|
||||
Semua kode baru (controller, service, request, model, enum, komponen React) **wajib** mengikuti dokumen ini. Saat mengubah kode lama yang menyimpang, samakan dengan konvensi di sini sekalian (boy scout rule), kecuali perubahan di luar scope task yang sedang dikerjakan.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsistensi Struktur & Penamaan
|
||||
|
||||
- **Nama method harus konsisten** untuk fungsi yang setara. Jangan sampai controller A punya `store()` tapi controller B untuk hal yang sama pakai `save()`.
|
||||
- **Urutan method harus konsisten** di semua Controller, Service, Model, dan class lain. Urutan baku untuk resource controller:
|
||||
1. `index()`
|
||||
2. `create()` (jika ada, hanya untuk non-Inertia modal-less form)
|
||||
3. `store()`
|
||||
4. `show()` (jika ada)
|
||||
5. `edit()` (jika ada)
|
||||
6. `update()`
|
||||
7. `destroy()`
|
||||
8. Method kustom lain (`updateStatus()`, `resetPassword()`, dll) **selalu ditaruh setelah** ketujuh method di atas, bukan disisipkan di tengah.
|
||||
|
||||
Contoh salah — method kustom nyempil di antara `update()` dan `destroy()`:
|
||||
```php
|
||||
index(), create(), store(), edit(), update(), resetPassword(), destroy()
|
||||
```
|
||||
Contoh benar:
|
||||
```php
|
||||
index(), create(), store(), edit(), update(), destroy(), resetPassword()
|
||||
```
|
||||
- **Organisasi Controller/Request/Service by domain.** Kelompokkan berdasarkan domain bisnis project yang bersangkutan, konsisten di ketiga layer:
|
||||
```
|
||||
app/Http/Controllers/{Domain}/{Name}Controller.php
|
||||
app/Http/Requests/{Domain}/{Name}Request.php
|
||||
app/Services/{Domain}/{Name}Service.php
|
||||
```
|
||||
Domain ditentukan oleh kebutuhan project (mis. `Manage`, `Master`, `Finances`, `Users`), bukan nama fitur individual. Class baru yang berhubungan masuk ke domain yang sudah ada; jangan bikin domain baru untuk satu fitur kecil.
|
||||
- Ikuti konvensi penamaan Laravel community (PSR-12 + konvensi umum):
|
||||
|
||||
| Yang di-nama-i | Gaya | Contoh benar | Contoh salah |
|
||||
|---|---|---|---|
|
||||
| Controller | singular | `ArticleController` | `ArticlesController` |
|
||||
| Route (URI) | plural | `articles/1` | `article/1` |
|
||||
| Route name | snake_case + dot | `users.show_active` | `users.show-active` |
|
||||
| Model | singular | `User` | `Users` |
|
||||
| Relasi hasOne/belongsTo | singular | `articleComment` | `articleComments` |
|
||||
| Relasi lain (hasMany, dll) | plural | `articleComments` | `articleComment` |
|
||||
| Tabel | plural, snake_case | `article_comments` | `articleComments` |
|
||||
| Tabel pivot | singular, alfabetis | `article_user` | `user_article` |
|
||||
| Kolom tabel | snake_case, tanpa nama model | `meta_title` | `article_meta_title` |
|
||||
| Foreign key | singular model + `_id` | `article_id` | `id_article` |
|
||||
| Primary key | - | `id` | `custom_id` |
|
||||
| Migration | deskriptif | `2017_01_01_000000_create_articles_table` | `2017_01_01_000000_articles` |
|
||||
| Method | camelCase | `getAll()` | `get_all()` |
|
||||
| Method resource controller | verba standar | `store()` | `saveArticle()` |
|
||||
| Method test (Pest) | deskriptif, `it(...)`/`test(...)` | `it('rejects guest from viewing article')` | — |
|
||||
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
||||
| Collection | deskriptif, plural | `$activeUsers` | `$data` |
|
||||
| Object tunggal | deskriptif, singular | `$activeUser` | `$obj` |
|
||||
| File config/lang index | snake_case | `articles_enabled` | `ArticlesEnabled` |
|
||||
| File view Blade | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
||||
| Komponen React (Inertia page/props) | PascalCase file, camelCase prop | `EditForm.tsx`, `isOpen` | — |
|
||||
| Contract (interface) | adjective/noun, tanpa prefix `I` | `AuthenticationInterface` | `IAuthentication` |
|
||||
| Trait | adjective | `Notifiable` | `NotificationTrait` |
|
||||
| Enum | singular | `UserType` | `UserTypeEnum` |
|
||||
| FormRequest | singular + `Request` | `UpdateUserRequest` | `UserFormRequest` |
|
||||
| Seeder | singular | `UserSeeder` | `UsersSeeder` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Enum
|
||||
|
||||
- Semua data opsi yang sudah pasti/tetap valuenya **wajib** pakai native PHP Enum (`enum ... : string`), jangan pakai konstanta class atau string mentah.
|
||||
- Setiap Enum yang punya representasi UI **wajib** menyediakan method `label(): string` berbahasa Indonesia:
|
||||
```php
|
||||
enum OrderStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Completed = 'completed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Menunggu',
|
||||
self::Completed => 'Selesai',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
- Kalau project punya banyak Enum, buat trait bantu (mis. `HasValues`) untuk method umum seperti `values()`/`options()` supaya tidak duplikat logika di tiap enum.
|
||||
- Validasi enum di FormRequest pakai `Rule::enum(XxxEnum::class)`, bukan `Rule::in([...])` manual — termasuk kalau ditulis sebagai `Rule::in(XxxEnum::values())`, itu tetap salah karena tidak mendapat pesan error bawaan enum dan gampang lolos review karena polanya mirip valid:
|
||||
```php
|
||||
// Bad
|
||||
'status' => ['required', 'string', Rule::in(OrderStatus::values())],
|
||||
|
||||
// Good
|
||||
'status' => ['required', 'string', Rule::enum(OrderStatus::class)],
|
||||
```
|
||||
`Rule::in([...])` tetap sah dipakai untuk daftar nilai tetap yang **belum** dijadikan Enum (mis. sedang dipertimbangkan atau memang bukan kandidat Enum) — tapi begitu ada Enum untuk field itu, wajib pindah ke `Rule::enum()`.
|
||||
- Kirim enum ke frontend sebagai `value` + `label`, jangan kirim instance PHP mentah ke Inertia props — gunakan `->value` dan `->label()` secara eksplisit atau resource/mapper kecil.
|
||||
|
||||
---
|
||||
|
||||
## 3. Model & Eloquent
|
||||
|
||||
- Gunakan `#[Guarded(['id'])]` (attribute), **bukan** `protected $guarded` atau `#[Fillable]`. Tambahkan kolom lain ke `Guarded` hanya jika memang tidak boleh diisi lewat mass assignment (mis. `last_login_at`).
|
||||
- **Hapus model/class yang sudah tidak dipakai**, jangan dibiarkan menumpuk. Cek dengan grep nama class-nya (bukan cuma teks labelnya) sebelum menyimpulkan tidak terpakai — model kosong tanpa `#[Guarded]` dan tanpa referensi pemakaian adalah tanda kuat dead code.
|
||||
- **Selalu buat relasi dua arah.** Kalau `Order belongsTo Customer`, maka `Customer` juga harus punya relasi baliknya (`hasOne`/`hasMany` sesuai kardinalitas). Jangan biarkan relasi hanya berjalan satu arah.
|
||||
- **Jangan asumsikan foreign key default Eloquent tanpa mengecek migration.** Sebelum menulis `hasMany`/`hasOne`/`belongsTo` tanpa parameter FK eksplisit, pastikan kolom hasil konvensi (`{model}_id`) memang ada di tabel terkait. Kalau kolomnya berbeda (mis. relasi menyeberang lewat model perantara), gunakan FK eksplisit atau `hasManyThrough`, bukan dibiarkan salah diam-diam.
|
||||
- **Urutkan method relasi dalam satu model per kelompok tipe, lalu alfabetis di dalam tiap kelompok**, dengan urutan kelompok: `belongsTo` → `hasOne` → `hasMany` → `belongsToMany` → `hasOneThrough`/`hasManyThrough` → relasi morph (`morphTo`/`morphMany`/`morphToMany`). Method non-relasi (`casts()`, accessor, method bisnis custom) tetap di posisi semula relatif terhadap blok relasi.
|
||||
```php
|
||||
// Good — dikelompokkan per tipe, lalu alfabetis
|
||||
public function department(): BelongsTo { ... } // belongsTo
|
||||
public function user(): BelongsTo { ... }
|
||||
|
||||
public function profile(): HasOne { ... } // hasOne
|
||||
|
||||
public function attendances(): HasMany { ... } // hasMany
|
||||
public function submissions(): HasMany { ... }
|
||||
|
||||
public function departments(): BelongsToMany { ... } // belongsToMany
|
||||
```
|
||||
- Eloquent-first, hindari raw query/`DB::` kecuali untuk kasus performa spesifik yang benar-benar butuh (agregasi berat, bulk update) — dan beri komentar alasannya.
|
||||
- **Cegah N+1**: selalu eager-load relasi yang dipakai di view/Inertia props dengan `with()`/`load()`. Saat memakai partial eager load (`with('term:id,start_date,end_date')`), pastikan kolom yang dipakai untuk cast/accessor ikut disertakan, atau serialisasi akan error.
|
||||
- Mass assignment lewat relasi, bukan set atribut manual satu-satu:
|
||||
```php
|
||||
// Bad
|
||||
$article = new Article;
|
||||
$article->title = $request->title;
|
||||
$article->category_id = $category->id;
|
||||
$article->save();
|
||||
|
||||
// Good
|
||||
$category->articles()->create($request->validated());
|
||||
```
|
||||
- Untuk data besar (export, batch update, notifikasi massal), gunakan `chunk()`/`chunkById()`/`cursor()`, jangan `get()` lalu `foreach` penuh di memori:
|
||||
```php
|
||||
// Bad
|
||||
foreach (User::all() as $user) { ... }
|
||||
|
||||
// Good
|
||||
User::chunkById(500, function ($users) {
|
||||
foreach ($users as $user) { ... }
|
||||
});
|
||||
```
|
||||
- Query builder singkat & ekspresif:
|
||||
|
||||
| Panjang | Singkat |
|
||||
|---|---|
|
||||
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||
| `->select('id', 'name')->get()` | `->get(['id', 'name'])` |
|
||||
| `->first()->name` | `->value('name')` |
|
||||
|
||||
Pola `->get([...])`/`->paginate($perPage, [...])` ini berlaku juga walau ada `with()`/`when()`/`join()`/dll di antara — Eloquent tidak peduli di posisi mana `select()` dipanggil relatif ke klausa lain, cuma peduli klausa itu ada sebelum eksekusi. **Kecuali** kalau query yang sama pakai `withCount()`/`withSum()`/`withAvg()`/`withMax()`/`withMin()`: method-method itu diam-diam menyuntik `select(table.*)` kalau belum ada `select()` eksplisit sebelumnya, dan Eloquent hanya menerapkan kolom dari `get($columns)`/`paginate($perPage, $columns)` kalau belum ada `select()` — begitu `withCount()` lebih dulu mengisi kolom, argumen kolom di method terminal **diabaikan diam-diam tanpa error** (balik jadi `select(*)`, bocor semua kolom). Jadi kalau ada `withCount()`/`withSum()`/dst di query, `select([...])` eksplisit **wajib** tetap dipertahankan sebelum pemanggilan `withCount()`/dst, jangan dipindah ke method terminal.
|
||||
|
||||
---
|
||||
|
||||
## 4. Controller, Request, dan Service
|
||||
|
||||
- **Controller** hanya mengatur alur request → response (validasi input dipanggil, service dipanggil, redirect/Inertia render dikembalikan). **Tidak boleh** ada query Eloquent kompleks atau business logic langsung di controller.
|
||||
- **Selalu gunakan FormRequest**, sekecil apapun validasinya — jangan validasi inline di controller dengan `$request->validate()`.
|
||||
- Business logic (kalkulasi, orkestrasi antar model, side effect seperti notifikasi/log) **disimpan di Service**, bukan di controller atau model.
|
||||
- **Kalau method Service butuh user yang sedang login untuk scoping/filtering** (mis. dosen cuma lihat kelasnya sendiri, mahasiswa cuma lihat jurusannya sendiri), terima sebagai parameter eksplisit `User $user` (non-nullable, tanpa default) di **posisi pertama** — jangan panggil `auth()->user()` langsung di dalam Service. Controller yang menyuplainya lewat `$request->user()`. Ini soal testability (Service tidak bergantung diam-diam ke global state) dan konsistensi lintas Service.
|
||||
```php
|
||||
// Bad
|
||||
public function paginated(int $perPage = 25): LengthAwarePaginator
|
||||
{
|
||||
$user = auth()->user();
|
||||
// ...
|
||||
}
|
||||
|
||||
// Good
|
||||
public function paginated(User $user, int $perPage = 25): LengthAwarePaginator
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
- Otorisasi berbasis permission (Spatie) dicek di dua tempat:
|
||||
- Route-level: middleware `permission:create-xxx` dipasang per-route/per-group.
|
||||
- Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. Untuk route tanpa FormRequest (mis. `destroy()` yang tidak butuh validasi input), pengecekan yang sama dilakukan inline dengan `abort_if()`/`abort_unless()` di Controller.
|
||||
- **Predikat kepemilikan itu sendiri ditaruh sebagai method di Model (`User`, atau model pemilik lain yang relevan), bukan didefinisikan ulang di tiap Controller/FormRequest yang butuh.** Satu aturan bisnis harus punya satu sumber kebenaran — supaya konsisten dan gampang di-test. Penamaan: `is<Peran>Of($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage<Model>($target)` untuk gate umum "boleh mengubah/menghapus record ini".
|
||||
```php
|
||||
// Bad — predikat yang sama diulang di FormRequest DAN Controller
|
||||
// (FormRequest)
|
||||
public function authorize(): bool
|
||||
{
|
||||
$assignment = $this->route('assignment');
|
||||
return $this->user()->can('update-assignments')
|
||||
&& (! $this->user()->hasRole('dosen') || $assignment->courseClass->lecturer_id === $this->user()->lecturer?->id);
|
||||
}
|
||||
// (Controller::destroy(), butuh predikat yang sama karena tidak ada FormRequest)
|
||||
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
|
||||
{
|
||||
$user = request()->user();
|
||||
abort_if($user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, 403);
|
||||
}
|
||||
|
||||
// Good — predikat di Model, dipakai dari FormRequest maupun Controller
|
||||
// (User model)
|
||||
public function canManageAssignment(Assignment $assignment): bool
|
||||
{
|
||||
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// (FormRequest)
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update-assignments')
|
||||
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||
}
|
||||
// (Controller::destroy())
|
||||
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||
```
|
||||
- Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method:
|
||||
```php
|
||||
// Bad
|
||||
$user = new User;
|
||||
$user->create($request->validated());
|
||||
|
||||
// Good
|
||||
public function __construct(protected UserService $userService) {}
|
||||
|
||||
$this->userService->create($request->validated());
|
||||
```
|
||||
- **Single Responsibility** — satu method cuma ngerjain satu hal:
|
||||
```php
|
||||
// Bad
|
||||
public function update(Request $request): string
|
||||
{
|
||||
$validated = $request->validate([...]);
|
||||
foreach ($request->events as $event) {
|
||||
$date = $this->carbon->parse($event['date'])->toString();
|
||||
$this->logger->log('Update event ' . $date);
|
||||
}
|
||||
$this->event->updateGeneralEvent($request->validated());
|
||||
return back();
|
||||
}
|
||||
|
||||
// Good
|
||||
public function update(UpdateEventRequest $request): RedirectResponse
|
||||
{
|
||||
$this->logService->logEvents($request->events);
|
||||
$this->eventService->updateGeneralEvent($request->validated());
|
||||
return back();
|
||||
}
|
||||
```
|
||||
- Pecah method yang melakukan banyak hal jadi beberapa method kecil dengan nama deskriptif:
|
||||
```php
|
||||
// Bad
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
if (auth()->user() && auth()->user()->hasRole('client') && auth()->user()->isVerified()) {
|
||||
return 'Mr. ' . $this->first_name . ' ' . $this->last_name;
|
||||
}
|
||||
return $this->first_name[0] . '. ' . $this->last_name;
|
||||
}
|
||||
|
||||
// Good
|
||||
public function getFullNameAttribute(): string
|
||||
{
|
||||
return $this->isVerifiedClient() ? $this->getFullNameLong() : $this->getFullNameShort();
|
||||
}
|
||||
|
||||
public function isVerifiedClient(): bool { ... }
|
||||
public function getFullNameLong(): string { ... }
|
||||
public function getFullNameShort(): string { ... }
|
||||
```
|
||||
- Return type selalu dideklarasikan secara eksplisit (`: Response`, `: RedirectResponse`, `: Collection`, dll).
|
||||
- Untuk operasi yang menyentuh >1 tabel sekaligus (mis. buat record + update counter terkait), bungkus dengan `DB::transaction()` di dalam Service, supaya atomik.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kode Ringkas & Idiomatis Laravel
|
||||
|
||||
Gunakan helper singkat yang sudah tersedia, jangan syntax panjang manual:
|
||||
|
||||
| Syntax panjang | Syntax ringkas |
|
||||
|---|---|
|
||||
| `Session::get('cart')` | `session('cart')` |
|
||||
| `$request->session()->get('cart')` | `session('cart')` |
|
||||
| `Session::put('cart', $data)` | `session(['cart' => $data])` |
|
||||
| `$request->input('name')` | `$request->name` / `request('name')` |
|
||||
| `return Redirect::back()` | `return back()` |
|
||||
| `is_null($obj->relation) ? null : $obj->relation->id` | `$obj->relation?->id` |
|
||||
| `return view('index')->with('title', $t)->with('client', $c)` | `return view('index', compact('title', 'client'))` |
|
||||
| `$request->has('value') ? $request->value : 'default'` | `$request->get('value', 'default')` |
|
||||
| `Carbon::now()`, `Carbon::today()` | `now()`, `today()` |
|
||||
| `App::make('Class')` | `app('Class')` |
|
||||
|
||||
- **Jangan panggil `env()` di luar file config.** Simpan dulu ke `config/*.php`, lalu akses lewat `config('nama.key')`. Ini berlaku juga untuk kredensial/flag baru yang ditambahkan.
|
||||
- **DocBlock boleh dipakai kalau memang dibutuhkan dan penting** — bukan default yang dipasang di semua method. Untuk method standar (CRUD biasa, atau apa pun yang sudah jelas maksudnya dari nama method + return/param type PHP), **jangan** tambah DocBlock — itu pemborosan. DocBlock baru layak ditulis untuk kasus yang memang penting, misalnya:
|
||||
1. **Generic type** yang tidak bisa diekspresikan native PHP, mis. `@return Collection<int, Student>`, `@param array<int, string>`.
|
||||
2. **Perilaku non-obvious** yang bisa bikin pembaca salah paham kalau tidak dijelaskan: aturan bisnis tersembunyi, constraint yang tidak kelihatan dari nama method, workaround keterbatasan interface/library.
|
||||
|
||||
```php
|
||||
// Bad — DocBlock generik yang cuma mengulang nama method, tidak nambah informasi
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array { ... }
|
||||
|
||||
// Good — tanpa DocBlock sama sekali, karena nama + return type sudah cukup jelas
|
||||
public function rules(): array { ... }
|
||||
|
||||
// Good — DocBlock dipertahankan karena memang menjelaskan hal yang tidak
|
||||
// kelihatan dari signature method (fallback logic yang bisa mengejutkan)
|
||||
/**
|
||||
* Falls back to the one with the latest start date if none is explicitly active.
|
||||
*/
|
||||
public function getActive(): ?AcademicTerm { ... }
|
||||
```
|
||||
- Komentar kode hanya untuk menjelaskan **kenapa**, bukan **apa** — kalau nama variabel/method sudah jelas, jangan tambah komentar.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend (Inertia + React + TypeScript)
|
||||
|
||||
- **Selalu jalankan `php artisan wayfinder:generate --with-form`** setelah menambah/mengubah route, controller, atau FormRequest — flag `--with-form` wajib, tanpa itu halaman auth/settings gagal `tsc`.
|
||||
- Panggil route lewat Wayfinder helper yang di-generate (`import { store } from '@/routes/...'`), **jangan** hardcode string URL di komponen React.
|
||||
- Props yang dikirim dari controller ke halaman Inertia harus sudah dalam bentuk final untuk UI (enum sudah di-`value`/`label`, tanggal sudah diformat/di-ISO-kan) — jangan lempar model Eloquent mentah tanpa transformasi eksplisit.
|
||||
- Komponen React: file `PascalCase.tsx`, satu komponen per file untuk komponen yang di-export dan dipakai di tempat lain; komponen kecil khusus halaman boleh co-located di file yang sama.
|
||||
- Tipe untuk data dari backend didefinisikan eksplisit (interface/type), jangan `any`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- Test ditulis dengan **Pest**, bukan gaya PHPUnit class-based, kecuali menyentuh kode lama yang belum dimigrasikan.
|
||||
- Nama test deskriptif dan menyatakan perilaku, bukan nama method: `it('rejects guest from updating another user's profile')`.
|
||||
- Untuk endpoint yang dilindungi permission, test **minimal** mencakup: pengguna dengan permission (berhasil), pengguna tanpa permission (403), dan — kalau ada object-level authorization — kasus user lain yang mencoba mengakses record bukan miliknya (ditolak).
|
||||
- Gunakan factory (`Model::factory()`), jangan insert manual ke DB di test.
|
||||
- Jalankan `php artisan test` (atau `vendor/bin/pest`) sebelum menganggap task selesai jika ada perubahan pada Service/Controller/Request.
|
||||
|
||||
---
|
||||
|
||||
## 8. Kualitas Kode & Tooling
|
||||
|
||||
- Jalankan **Laravel Pint** (`vendor/bin/pint --parallel`) sebelum commit — style harus konsisten, jangan format manual.
|
||||
- Jalankan **Larastan** (`vendor/bin/phpstan analyse`) untuk perubahan yang menyentuh tipe/return value; perbaiki temuannya, jangan suppress kecuali benar-benar false positive dan beri alasan.
|
||||
- Jangan gunakan `@phpstan-ignore` / `@ts-ignore` sebagai jalan pintas — perbaiki akar masalah tipe-nya.
|
||||
|
||||
---
|
||||
|
||||
## 9. Keamanan
|
||||
|
||||
- Validasi selalu di FormRequest (lihat §4), termasuk validasi kepemilikan record lewat `Rule::exists()` yang di-scope, bukan hanya cek ID ada di tabel mana pun.
|
||||
- Jangan expose data sensitif (password hash, token) lewat Inertia props atau API resource — pastikan `Guarded`/`hidden` pada model sudah benar dan props controller hanya kirim field yang dibutuhkan.
|
||||
- Upload file lewat satu service upload terpusat per project, jangan tulis logic upload baru per fitur — validasi mime/size selalu di FormRequest terkait.
|
||||
- Permission baru yang ditambahkan harus terdaftar di seeder permission (Spatie) dan dipasang di route lewat `permission:` middleware.
|
||||
27
app/Enums/DegreeLevel.php
Normal file
27
app/Enums/DegreeLevel.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\HasValues;
|
||||
|
||||
enum DegreeLevel: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case D3 = 'D3';
|
||||
case D4 = 'D4';
|
||||
case S1 = 'S1';
|
||||
case S2 = 'S2';
|
||||
case S3 = 'S3';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::D3 => 'D3 (Diploma Tiga)',
|
||||
self::D4 => 'D4 (Diploma Empat)',
|
||||
self::S1 => 'S1 (Sarjana)',
|
||||
self::S2 => 'S2 (Magister)',
|
||||
self::S3 => 'S3 (Doktor)',
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/Enums/UserRole.php
Normal file
29
app/Enums/UserRole.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Enums\Concerns\HasValues;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case Mahasiswa = 'mahasiswa';
|
||||
case Dosen = 'dosen';
|
||||
case Kaprodi = 'kaprodi';
|
||||
case StaffAdmin = 'staff-admin';
|
||||
case StaffKeuangan = 'staff-keuangan';
|
||||
case Developer = 'developer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Mahasiswa => 'Mahasiswa',
|
||||
self::Dosen => 'Dosen',
|
||||
self::Kaprodi => 'Ketua Program Studi',
|
||||
self::StaffAdmin => 'Staff Admin',
|
||||
self::StaffKeuangan => 'Staff Keuangan',
|
||||
self::Developer => 'Developer',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ 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,
|
||||
@ -32,7 +33,7 @@ public function title(): string
|
||||
|
||||
public function collection(): Enumerable
|
||||
{
|
||||
return app(StudentService::class)->forExport($this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear);
|
||||
return app(StudentService::class)->forExport($this->user, $this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear);
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
||||
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AcademicClasses\AssignmentRequest;
|
||||
use App\Http\Requests\Admin\AcademicClasses\Assignment\AssignmentRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Assignment;
|
||||
use App\Services\Admin\AcademicClasses\AssignmentService;
|
||||
use App\Services\Admin\Manage\CourseClassService;
|
||||
use App\Services\Admin\AcademicClasses\Assignment\AssignmentService;
|
||||
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
@ -23,23 +23,15 @@ 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: $academicTermId,
|
||||
academicTermId: $this->academicTermService->getActive()?->id,
|
||||
)),
|
||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||
'filters' => [
|
||||
'course_class_id' => $request->validated('course_class_id'),
|
||||
'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
|
||||
],
|
||||
'filters' => $request->only(['course_class_id']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -63,6 +55,8 @@ 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();
|
||||
@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
||||
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest;
|
||||
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest;
|
||||
use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
|
||||
use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
|
||||
use App\Models\Assignment;
|
||||
use App\Models\Submission;
|
||||
use App\Services\Admin\AcademicClasses\SubmissionService;
|
||||
use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -20,7 +20,7 @@ public function __construct(
|
||||
|
||||
public function index(Assignment $assignment): Response
|
||||
{
|
||||
$this->abortUnlessLecturerOwnsAssignment($assignment);
|
||||
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||
|
||||
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
|
||||
|
||||
@ -45,17 +45,4 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -34,17 +34,6 @@ public function index(Request $request): Response
|
||||
]);
|
||||
}
|
||||
|
||||
public function mine(Request $request): Response
|
||||
{
|
||||
$student = $request->user()->student;
|
||||
|
||||
abort_if(! $student, 403);
|
||||
|
||||
return Inertia::render('admin/academic-classes/attendances/mine', [
|
||||
'summaries' => $this->service->forStudent($student),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(CourseClass $courseClass): Response
|
||||
{
|
||||
$this->abortUnlessLecturerOwnsClass($courseClass);
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Material;
|
||||
use App\Services\Admin\AcademicClasses\MaterialService;
|
||||
use App\Services\Admin\Manage\CourseClassService;
|
||||
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -17,16 +18,18 @@ 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' => $this->service->paginated(
|
||||
'materials' => Inertia::scroll(fn () => $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']),
|
||||
]);
|
||||
@ -52,6 +55,8 @@ 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();
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@ -26,7 +27,7 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$isPersonalView = $user->hasRole('mahasiswa') || $user->hasRole('dosen');
|
||||
$isPersonalView = $user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value);
|
||||
|
||||
$academicTermId = $request->has('academic_term_id')
|
||||
? $request->validated('academic_term_id')
|
||||
|
||||
@ -22,11 +22,11 @@ public function index(PaginatedRequest $request): Response
|
||||
|
||||
return Inertia::render('admin/developer/logs/index', [
|
||||
'entries' => $file
|
||||
? $this->service->paginated(
|
||||
? Inertia::scroll(fn () => $this->service->paginated(
|
||||
$file,
|
||||
...$request->validatedWithDefaults(),
|
||||
level: $request->validated('level'),
|
||||
)
|
||||
))
|
||||
: null,
|
||||
'files' => $files,
|
||||
'selectedFile' => $file,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
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;
|
||||
@ -24,12 +25,12 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/feedback/index', [
|
||||
'feedbacks' => $this->service->paginated(
|
||||
'feedbacks' => Inertia::scroll(fn () => $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']),
|
||||
@ -55,6 +56,7 @@ 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,
|
||||
@ -64,8 +66,6 @@ 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,6 +73,16 @@ 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'));
|
||||
@ -80,12 +90,10 @@ public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $fee
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back();
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Feedback $feedback): RedirectResponse
|
||||
public function reply(ReplyFeedbackRequest $request, Feedback $feedback): RedirectResponse
|
||||
{
|
||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||
$this->service->reply($feedback, $request->validated('reply'));
|
||||
|
||||
$this->service->delete($feedback);
|
||||
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
|
||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Balasan berhasil dikirim.'])->back();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' => $this->service->paginated(
|
||||
'invoices' => Inertia::scroll(fn () => $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,
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
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;
|
||||
@ -16,18 +15,15 @@ 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' => $this->service->paginated(
|
||||
'announcements' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$request->validatedWithDefaults(),
|
||||
departmentId: $request->validated('department_id'),
|
||||
),
|
||||
'departments' => $this->departmentService->getAllForSelect(),
|
||||
'filters' => $request->only(['department_id']),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
namespace App\Http\Controllers\Admin\Manage\CourseClass;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\CourseClass;
|
||||
use App\Services\Admin\Manage\ClassEnrollmentService;
|
||||
use App\Services\Admin\Manage\CourseClass\ClassEnrollmentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
namespace App\Http\Controllers\Admin\Manage\CourseClass;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
||||
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest;
|
||||
use App\Http\Requests\Admin\Manage\CourseClass\CourseClassRequest;
|
||||
use App\Http\Requests\Admin\Manage\CourseClass\DuplicateCourseClassesRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\CourseClass;
|
||||
use App\Services\Admin\Manage\CourseClassService;
|
||||
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||
use App\Services\Admin\Master\AcademicTermService;
|
||||
use App\Services\Admin\Master\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' => $this->service->paginated(
|
||||
'courseClasses' => Inertia::scroll(fn () => $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(),
|
||||
'courseClassAssignments' => $this->service->getAllForSelect($request->user()),
|
||||
'filters' => $request->only(['academic_term_id', 'method']),
|
||||
]);
|
||||
}
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
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\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\PaginatedRequest;
|
||||
use App\Models\Course;
|
||||
use App\Models\CourseRegistrationSubmission;
|
||||
@ -25,30 +27,27 @@ public function __construct(
|
||||
|
||||
public function index(PaginatedRequest $request): Response|RedirectResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasRole('mahasiswa')) {
|
||||
if ($user->hasRole(UserRole::Mahasiswa->value)) {
|
||||
return to_route('admin.manage.course-registrations.show', $user->student->id);
|
||||
}
|
||||
|
||||
$isKaprodi = $user->hasRole('kaprodi');
|
||||
|
||||
return Inertia::render('admin/manage/course-registrations/index', [
|
||||
'departments' => $this->service->departmentSummary(),
|
||||
'registrations' => $this->service->paginated(
|
||||
'registrations' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$user,
|
||||
...$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 = auth()->user();
|
||||
$isStudent = $user->hasRole('mahasiswa');
|
||||
$user = request()->user();
|
||||
$isStudent = $user->hasRole(UserRole::Mahasiswa->value);
|
||||
|
||||
abort_if($isStudent && $user->student->id !== $student->id, 403);
|
||||
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
|
||||
@ -61,7 +60,7 @@ public function show(Student $student): Response
|
||||
&& $user->can('approve-course-registrations')
|
||||
&& $user->canReviewCourseRegistrationOf($student);
|
||||
|
||||
$canSignAsKaprodi = $user->isKaprodiOf($student);
|
||||
$canSignAsDepartmentLeader = $user->isDepartmentLeaderOf($student);
|
||||
$canSignAsAdvisor = $user->isAdvisorOf($student);
|
||||
|
||||
$activeTerm = $this->academicTermService->getActive();
|
||||
@ -97,12 +96,12 @@ public function show(Student $student): Response
|
||||
'hasActiveTerm' => $activeTerm !== null,
|
||||
'backHref' => $backHref,
|
||||
'canReview' => $canReview,
|
||||
'canSignAsKaprodi' => $canSignAsKaprodi,
|
||||
'canSignAsDepartmentLeader' => $canSignAsDepartmentLeader,
|
||||
'canSignAsAdvisor' => $canSignAsAdvisor,
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse
|
||||
public function update(Student $student, string $semester, UpdateCourseRegistrationRequest $request): RedirectResponse
|
||||
{
|
||||
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
|
||||
|
||||
@ -138,23 +137,19 @@ public function save(Student $student, string $semester, SaveCourseRegistrationR
|
||||
return to_route('admin.manage.course-registrations.show', $student->id);
|
||||
}
|
||||
|
||||
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
|
||||
public function signAsDepartmentLeader(Student $student, string $semester, SignAsDepartmentLeaderCourseRegistrationRequest $request): RedirectResponse
|
||||
{
|
||||
abort_unless(auth()->user()->isKaprodiOf($student), 403);
|
||||
|
||||
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
||||
|
||||
$this->service->signAsKaprodi($submission, $request->file('signature'));
|
||||
$this->service->signAsDepartmentLeader($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, SignCourseRegistrationRequest $request): RedirectResponse
|
||||
public function signAsAdvisor(Student $student, string $semester, SignAsAdvisorCourseRegistrationRequest $request): RedirectResponse
|
||||
{
|
||||
abort_unless(auth()->user()->isAdvisorOf($student), 403);
|
||||
|
||||
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
||||
|
||||
$this->service->signAsAdvisor($submission, $request->file('signature'));
|
||||
@ -166,7 +161,11 @@ public function signAsAdvisor(Student $student, string $semester, SignCourseRegi
|
||||
|
||||
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
|
||||
{
|
||||
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
|
||||
abort_unless(
|
||||
request()->user()->can('approve-course-registrations')
|
||||
&& request()->user()->canReviewCourseRegistrationOf($submission->student),
|
||||
403
|
||||
);
|
||||
|
||||
$this->service->approve($submission);
|
||||
|
||||
@ -175,8 +174,6 @@ 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();
|
||||
|
||||
@ -21,11 +21,11 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/master/academic-terms/index', [
|
||||
'academicTerms' => $this->service->paginated(
|
||||
'academicTerms' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
semester: $request->validated('semester'),
|
||||
isActive: $request->filled('is_active') ? filter_var($request->validated('is_active'), FILTER_VALIDATE_BOOLEAN) : null,
|
||||
),
|
||||
isActive: $request->filled('is_active') ? $request->boolean('is_active') : null,
|
||||
)),
|
||||
'filters' => $request->only(['semester', 'is_active']),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -22,11 +22,12 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/master/courses/index', [
|
||||
'courses' => $this->service->paginated(
|
||||
'courses' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$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']),
|
||||
|
||||
@ -22,7 +22,7 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/master/departments/index', [
|
||||
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
|
||||
'departments' => Inertia::scroll(fn () => $this->service->paginated(...$request->validatedWithDefaults())),
|
||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -24,10 +24,11 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/services/academic-advising-logs/index', [
|
||||
'logs' => $this->service->paginated(
|
||||
'logs' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$request->validatedWithDefaults(),
|
||||
lecturerId: $request->validated('lecturer_id'),
|
||||
),
|
||||
)),
|
||||
'students' => $this->studentService->getAllForSelect(),
|
||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||
'filters' => $request->only(['lecturer_id']),
|
||||
|
||||
@ -22,10 +22,11 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/services/letter-requests/index', [
|
||||
'letterRequests' => $this->service->paginated(
|
||||
'letterRequests' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$request->validatedWithDefaults(),
|
||||
status: $request->validated('status'),
|
||||
),
|
||||
)),
|
||||
'filters' => $request->only(['status']),
|
||||
]);
|
||||
}
|
||||
@ -53,7 +54,7 @@ public function update(LetterRequestRequest $request, LetterRequest $letterReque
|
||||
|
||||
public function destroy(LetterRequest $letterRequest): RedirectResponse
|
||||
{
|
||||
abort_if($letterRequest->status !== LetterStatus::Submitted->value, 403);
|
||||
abort_if($letterRequest->status !== LetterStatus::Submitted, 403);
|
||||
|
||||
$this->service->delete($letterRequest);
|
||||
|
||||
|
||||
@ -11,7 +11,6 @@
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class AdministratorController extends Controller
|
||||
{
|
||||
@ -22,10 +21,10 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/users/administrators/index', [
|
||||
'administrators' => $this->service->paginated(
|
||||
'administrators' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
gender: $request->validated('gender'),
|
||||
),
|
||||
)),
|
||||
'filters' => $request->only(['gender']),
|
||||
]);
|
||||
}
|
||||
@ -33,7 +32,7 @@ public function index(PaginatedRequest $request): Response
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/users/administrators/create', [
|
||||
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
|
||||
'roles' => $this->service->availableRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -52,7 +51,7 @@ public function edit(User $user): Response
|
||||
|
||||
return Inertia::render('admin/users/administrators/edit', [
|
||||
'user' => $user,
|
||||
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
|
||||
'roles' => $this->service->availableRoles(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -26,11 +26,11 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/users/lecturers/index', [
|
||||
'lecturers' => $this->service->paginated(
|
||||
'lecturers' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
gender: $request->validated('gender'),
|
||||
departmentId: $request->validated('department_id'),
|
||||
),
|
||||
)),
|
||||
'departments' => $this->departmentService->getAllForSelect(),
|
||||
'filters' => $request->only(['gender', 'department_id']),
|
||||
]);
|
||||
|
||||
@ -30,13 +30,14 @@ public function __construct(
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/users/students/index', [
|
||||
'students' => $this->service->paginated(
|
||||
'students' => Inertia::scroll(fn () => $this->service->paginated(
|
||||
$request->user(),
|
||||
...$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(),
|
||||
@ -112,6 +113,7 @@ 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'),
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
||||
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use App\Enums\AssignmentStatus;
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -10,7 +11,12 @@ class AssignmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can($this->isMethod('post') ? 'create-assignments' : 'update-assignments');
|
||||
if ($this->isMethod('post')) {
|
||||
return $this->user()->can('create-assignments');
|
||||
}
|
||||
|
||||
return $this->user()->can('update-assignments')
|
||||
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
@ -19,7 +25,7 @@ public function rules(): array
|
||||
|
||||
$courseClassRule = Rule::exists('course_classes', 'id');
|
||||
|
||||
if ($user->hasRole('dosen')) {
|
||||
if ($user->hasRole(UserRole::Dosen->value)) {
|
||||
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
||||
}
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GradeSubmissionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update-assignment-submissions')
|
||||
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
||||
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use App\Enums\AssignmentStatus;
|
||||
use App\Enums\RegistrationStatus;
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GradeSubmissionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
if (! $this->user()->can('update-assignment-submissions')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->user();
|
||||
$assignment = $this->route('assignment');
|
||||
|
||||
if ($user->hasRole('dosen') && $assignment?->courseClass?->lecturer_id !== $user->lecturer?->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -9,7 +10,12 @@ class MaterialRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can($this->isMethod('post') ? 'create-materials' : 'update-materials');
|
||||
if ($this->isMethod('post')) {
|
||||
return $this->user()->can('create-materials');
|
||||
}
|
||||
|
||||
return $this->user()->can('update-materials')
|
||||
&& $this->user()->canManageMaterial($this->route('material'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
@ -18,7 +24,7 @@ public function rules(): array
|
||||
|
||||
$courseClassRule = Rule::exists('course_classes', 'id');
|
||||
|
||||
if ($user->hasRole('dosen')) {
|
||||
if ($user->hasRole(UserRole::Dosen->value)) {
|
||||
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
||||
}
|
||||
|
||||
|
||||
@ -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::in(DayOfWeek::values())],
|
||||
'day_of_week' => ['required', 'string', Rule::enum(DayOfWeek::class)],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['required', 'date_format:H:i', 'after:start_time'],
|
||||
'room' => ['nullable', 'string', 'max:20'],
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin\Feedback;
|
||||
|
||||
use App\Enums\FeedbackStatus;
|
||||
use App\Enums\FeedbackType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
@ -10,13 +11,21 @@ class FeedbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback');
|
||||
if ($this->isMethod('post')) {
|
||||
return $this->user()->can('create-feedback');
|
||||
}
|
||||
|
||||
$feedback = $this->route('feedback');
|
||||
|
||||
return $this->user()->can('update-feedback')
|
||||
&& $feedback->user_id === $this->user()->id
|
||||
&& $feedback->status === FeedbackStatus::Submitted;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', Rule::in(FeedbackType::values())],
|
||||
'type' => ['required', 'string', Rule::enum(FeedbackType::class)],
|
||||
'subject' => ['required', 'string', 'max:150'],
|
||||
'message' => ['required', 'string'],
|
||||
];
|
||||
|
||||
20
app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php
Normal file
20
app/Http/Requests/Admin/Feedback/ReplyFeedbackRequest.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Feedback;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReplyFeedbackRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('reply-feedback');
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reply' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -17,8 +18,10 @@ public function rules(): array
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:200'],
|
||||
'content' => ['required', 'string'],
|
||||
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')],
|
||||
'enrollment_year' => ['nullable', 'integer', 'digits:4'],
|
||||
'target_roles' => ['required', 'array', 'min:1'],
|
||||
'target_roles.*' => [
|
||||
Rule::enum(UserRole::class)->only([UserRole::Mahasiswa, UserRole::Dosen]),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
namespace App\Http\Requests\Admin\Manage\CourseClass;
|
||||
|
||||
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::in(ClassMethod::values())],
|
||||
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||
];
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ function ($attribute, $value, $fail) {
|
||||
Rule::exists('lecturer_department', 'lecturer_id')->where('department_id', $departmentId),
|
||||
],
|
||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
|
||||
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
namespace App\Http\Requests\Admin\Manage\CourseClass;
|
||||
|
||||
use App\Models\AcademicTerm;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
@ -8,7 +8,8 @@ class RejectCourseRegistrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('reject-course-registrations');
|
||||
return $this->user()->can('reject-course-registrations')
|
||||
&& $this->user()->canReviewCourseRegistrationOf($this->route('submission')->student);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
@ -17,11 +18,4 @@ public function rules(): array
|
||||
'reason' => ['required', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'reason.required' => 'Alasan penolakan wajib diisi.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SignCourseRegistrationRequest extends FormRequest
|
||||
class SignAsAdvisorCourseRegistrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->user()->isAdvisorOf($this->route('student'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SignAsDepartmentLeaderCourseRegistrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->isDepartmentLeaderOf($this->route('student'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'signature' => ['required', 'file', 'image', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SaveCourseRegistrationRequest extends FormRequest
|
||||
class UpdateCourseRegistrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$student = $this->route('student');
|
||||
|
||||
return $this->user()->hasRole('mahasiswa')
|
||||
return $this->user()->hasRole(UserRole::Mahasiswa->value)
|
||||
&& $this->user()->student?->id === $student?->id;
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@ function ($attribute, $value, $fail) {
|
||||
'semester' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in(Semester::values()),
|
||||
Rule::enum(Semester::class),
|
||||
Rule::unique('academic_terms')->where(function ($query) {
|
||||
return $query->where('academic_year', $this->input('academic_year'));
|
||||
})->ignore($this->route('academic_term')),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use App\Enums\DegreeLevel;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -22,7 +23,7 @@ public function rules(): array
|
||||
Rule::unique('departments')->ignore($this->route('department')),
|
||||
],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
|
||||
'degree_level' => ['nullable', 'string', Rule::enum(DegreeLevel::class)],
|
||||
'lecturer_id' => [
|
||||
'nullable',
|
||||
Rule::requiredIf($this->route('department') !== null),
|
||||
|
||||
@ -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->value;
|
||||
&& $letterRequest->status === LetterStatus::Submitted;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Admin\Users;
|
||||
|
||||
use App\Enums\Gender;
|
||||
use App\Enums\UserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@ -37,13 +38,17 @@ public function rules(): array
|
||||
? Rule::unique('users')
|
||||
: Rule::unique('users')->ignore($userId),
|
||||
],
|
||||
'role' => ['required', 'string', Rule::in(['staff-admin', 'staff-keuangan'])],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(UserRole::class)->only([UserRole::StaffAdmin, UserRole::StaffKeuangan]),
|
||||
],
|
||||
|
||||
// Profile
|
||||
'full_name' => ['required', 'string', 'max:150'],
|
||||
'phone_number' => ['required', 'string', 'max:20'],
|
||||
'address' => ['required', 'string'],
|
||||
'gender' => ['required', 'string', Rule::in(Gender::values())],
|
||||
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||
'birth_date' => ['required', 'date'],
|
||||
'birth_place' => ['required', 'string', 'max:100'],
|
||||
];
|
||||
|
||||
@ -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::in(Gender::values())],
|
||||
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||
'birth_date' => ['required', 'date'],
|
||||
'birth_place' => ['required', 'string', 'max:100'],
|
||||
|
||||
|
||||
@ -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::in(Gender::values())],
|
||||
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||
'birth_date' => ['required', 'date'],
|
||||
'birth_place' => ['required', 'string', 'max:100'],
|
||||
|
||||
@ -79,7 +79,7 @@ public function rules(): array
|
||||
'status' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::in(StudentStatus::values()),
|
||||
Rule::enum(StudentStatus::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in(StudentStatus::values())],
|
||||
'status' => ['required', 'string', Rule::enum(StudentStatus::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::in(Gender::values())],
|
||||
'gender' => ['nullable', 'string', Rule::enum(Gender::class)],
|
||||
'department_id' => ['nullable', 'integer'],
|
||||
'status' => ['nullable', 'string'],
|
||||
'enrollment_year' => ['nullable', 'integer'],
|
||||
'semester_number' => ['nullable', 'integer'],
|
||||
'semester' => ['nullable', 'string', Rule::in(Semester::values())],
|
||||
'semester' => ['nullable', 'string', Rule::enum(Semester::class)],
|
||||
'is_active' => ['nullable', Rule::in(['true', 'false'])],
|
||||
'academic_term_id' => ['nullable', 'integer'],
|
||||
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
|
||||
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||
'course_class_id' => ['nullable', 'integer'],
|
||||
'lecturer_id' => ['nullable', 'integer'],
|
||||
'type' => ['nullable', 'string'],
|
||||
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
|
||||
'payment_method' => ['nullable', 'string', Rule::enum(PaymentMethod::class)],
|
||||
'file' => ['nullable', 'string'],
|
||||
'level' => ['nullable', 'string'],
|
||||
];
|
||||
|
||||
@ -13,13 +13,13 @@ class AcademicAdvisingLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function students(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Student::class);
|
||||
}
|
||||
|
||||
public function lecturer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class);
|
||||
}
|
||||
|
||||
public function students(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Student::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,21 @@ 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);
|
||||
|
||||
@ -13,9 +13,11 @@ class Announcement extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public function department(): BelongsTo
|
||||
protected function casts(): array
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
return [
|
||||
'target_roles' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
|
||||
@ -22,6 +22,11 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
}
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Course::class);
|
||||
@ -32,9 +37,14 @@ public function lecturer(): BelongsTo
|
||||
return $this->belongsTo(Lecturer::class);
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
return $this->hasMany(Assignment::class);
|
||||
}
|
||||
|
||||
public function attendances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function enrollments(): HasMany
|
||||
@ -47,23 +57,13 @@ public function materials(): HasMany
|
||||
return $this->hasMany(Material::class);
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
public function registrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(Assignment::class);
|
||||
return $this->hasMany(CourseRegistration::class);
|
||||
}
|
||||
|
||||
public function schedules(): HasMany
|
||||
{
|
||||
return $this->hasMany(Schedule::class);
|
||||
}
|
||||
|
||||
public function attendances(): HasMany
|
||||
{
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function registrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseRegistration::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,11 +12,6 @@ class CourseRegistration extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
@ -27,6 +22,11 @@ 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');
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])]
|
||||
#[Appends(['signature_url', 'department_leader_signature_url', 'advisor_signature_url'])]
|
||||
class CourseRegistrationSubmission extends Model implements HasMedia
|
||||
{
|
||||
use InteractsWithMedia;
|
||||
@ -31,15 +31,10 @@ protected function casts(): array
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('signature')->singleFile();
|
||||
$this->addMediaCollection('kaprodi_signature')->singleFile();
|
||||
$this->addMediaCollection('department_leader_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);
|
||||
@ -50,6 +45,11 @@ 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 kaprodiSignatureUrl(): Attribute
|
||||
protected function departmentLeaderSignatureUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null,
|
||||
get: fn () => $this->getFirstMediaUrl('department_leader_signature') ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -17,13 +17,13 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||
}
|
||||
|
||||
public function actor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'actor_id');
|
||||
}
|
||||
|
||||
public function submission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DegreeLevel;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -15,14 +16,21 @@ class Department extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public function lecturers(): BelongsToMany
|
||||
protected function casts(): array
|
||||
{
|
||||
return $this->belongsToMany(Lecturer::class, 'lecturer_department');
|
||||
return [
|
||||
'degree_level' => DegreeLevel::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function students(): HasMany
|
||||
public function currentLeader(): HasOne
|
||||
{
|
||||
return $this->hasMany(Student::class);
|
||||
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
||||
}
|
||||
|
||||
public function courses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Course::class);
|
||||
}
|
||||
|
||||
public function leaderships(): HasMany
|
||||
@ -30,8 +38,13 @@ public function leaderships(): HasMany
|
||||
return $this->hasMany(DepartmentLeadership::class);
|
||||
}
|
||||
|
||||
public function currentLeader(): HasOne
|
||||
public function students(): HasMany
|
||||
{
|
||||
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
||||
return $this->hasMany(Student::class);
|
||||
}
|
||||
|
||||
public function lecturers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Lecturer::class, 'lecturer_department');
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,16 +21,17 @@ protected function casts(): array
|
||||
return [
|
||||
'type' => FeedbackType::class,
|
||||
'status' => FeedbackStatus::class,
|
||||
'replied_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function handler(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'handled_by');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,9 +20,9 @@ public function user(): BelongsTo
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function departments(): BelongsToMany
|
||||
public function academicAdvisingLogs(): HasMany
|
||||
{
|
||||
return $this->belongsToMany(Department::class, 'lecturer_department');
|
||||
return $this->hasMany(AcademicAdvisingLog::class);
|
||||
}
|
||||
|
||||
public function advisees(): HasMany
|
||||
@ -30,13 +30,18 @@ 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 academicAdvisingLogs(): HasMany
|
||||
public function departments(): BelongsToMany
|
||||
{
|
||||
return $this->hasMany(AcademicAdvisingLog::class);
|
||||
return $this->belongsToMany(Department::class, 'lecturer_department');
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,16 +32,16 @@ public function registerMediaCollections(): void
|
||||
$this->addMediaCollection('letter_result')->singleFile();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function processor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'processed_by');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected function resultUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Mahasiswa extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
@ -19,13 +19,13 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
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'])]
|
||||
@ -22,9 +23,9 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
public function academicAdvisor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
@ -32,19 +33,14 @@ public function department(): BelongsTo
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function academicAdvisor(): BelongsTo
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function enrollments(): HasMany
|
||||
public function academicAdvisingLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClassEnrollment::class);
|
||||
}
|
||||
|
||||
public function submissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Submission::class);
|
||||
return $this->hasMany(AcademicAdvisingLog::class);
|
||||
}
|
||||
|
||||
public function attendances(): HasMany
|
||||
@ -52,11 +48,6 @@ public function attendances(): HasMany
|
||||
return $this->hasMany(Attendance::class);
|
||||
}
|
||||
|
||||
public function tuitionInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(TuitionInvoice::class);
|
||||
}
|
||||
|
||||
public function courseRegistrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseRegistration::class);
|
||||
@ -67,13 +58,30 @@ public function courseRegistrationSubmissions(): HasMany
|
||||
return $this->hasMany(CourseRegistrationSubmission::class);
|
||||
}
|
||||
|
||||
public function letterRequests(): HasMany
|
||||
public function enrollments(): HasMany
|
||||
{
|
||||
return $this->hasMany(LetterRequest::class);
|
||||
return $this->hasMany(ClassEnrollment::class);
|
||||
}
|
||||
|
||||
public function academicAdvisingLogs(): HasMany
|
||||
public function submissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademicAdvisingLog::class);
|
||||
return $this->hasMany(Submission::class);
|
||||
}
|
||||
|
||||
public function tuitionInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(TuitionInvoice::class);
|
||||
}
|
||||
|
||||
public function letterRequests(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(
|
||||
LetterRequest::class,
|
||||
User::class,
|
||||
'id',
|
||||
'user_id',
|
||||
'user_id',
|
||||
'id',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,16 +21,16 @@ protected function casts(): array
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function academicTerm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademicTerm::class);
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TuitionPayment::class, 'invoice_id');
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@ -38,6 +39,11 @@ 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);
|
||||
@ -48,14 +54,19 @@ public function student(): HasOne
|
||||
return $this->hasOne(Student::class);
|
||||
}
|
||||
|
||||
public function lecturer(): HasOne
|
||||
public function courseRegistrationSubmissionLogs(): HasMany
|
||||
{
|
||||
return $this->hasOne(Lecturer::class);
|
||||
return $this->hasMany(CourseRegistrationSubmissionLog::class, 'actor_id');
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
public function createdAnnouncements(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
return $this->hasMany(Announcement::class, 'created_by');
|
||||
}
|
||||
|
||||
public function createdNotifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class, 'created_by');
|
||||
}
|
||||
|
||||
public function feedbacks(): HasMany
|
||||
@ -63,40 +74,100 @@ public function feedbacks(): HasMany
|
||||
return $this->hasMany(Feedback::class);
|
||||
}
|
||||
|
||||
public function isAdvisorOf(Student $student): bool
|
||||
public function handledFeedbacks(): HasMany
|
||||
{
|
||||
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
||||
return $this->hasMany(Feedback::class, 'handled_by');
|
||||
}
|
||||
|
||||
public function isKaprodiOf(Student $student): bool
|
||||
public function letterRequests(): HasMany
|
||||
{
|
||||
return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
|
||||
return $this->hasMany(LetterRequest::class);
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notification::class);
|
||||
}
|
||||
|
||||
public function processedLetterRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(LetterRequest::class, 'processed_by');
|
||||
}
|
||||
|
||||
public function recordedTuitionPayments(): HasMany
|
||||
{
|
||||
return $this->hasMany(TuitionPayment::class, 'recorded_by');
|
||||
}
|
||||
|
||||
public function reviewedCourseRegistrationSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CourseRegistrationSubmission::class, 'reviewed_by');
|
||||
}
|
||||
|
||||
public function uploads(): HasMany
|
||||
{
|
||||
return $this->hasMany(EditorUpload::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates page access: the student's academic advisor, the kaprodi of
|
||||
* their department, or staff roles may view a student's KRS.
|
||||
* A dosen may only manage (update/delete) materials of classes they lecture.
|
||||
*/
|
||||
public function canAccessCourseRegistrationOf(Student $student): bool
|
||||
public function canManageMaterial(Material $material): bool
|
||||
{
|
||||
if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) {
|
||||
return $this->isAdvisorOf($student) || $this->isKaprodiOf($student);
|
||||
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||
return $material->courseClass->lecturer_id === $this->lecturer?->id;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the student's academic advisor may approve/reject; the kaprodi
|
||||
* signs but does not decide. Other staff roles retain override access.
|
||||
* 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;
|
||||
}
|
||||
|
||||
public function isAdvisorOf(Student $student): bool
|
||||
{
|
||||
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
||||
}
|
||||
|
||||
public function isDepartmentLeaderOf(Student $student): bool
|
||||
{
|
||||
return $this->hasRole(UserRole::Kaprodi->value) && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates page access: the student's academic advisor, the department
|
||||
* leader of their department, or staff roles may view a student's KRS.
|
||||
*/
|
||||
public function canAccessCourseRegistrationOf(Student $student): bool
|
||||
{
|
||||
if ($this->hasRole(UserRole::Dosen->value) || $this->hasRole(UserRole::Kaprodi->value)) {
|
||||
return $this->isAdvisorOf($student) || $this->isDepartmentLeaderOf($student);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the student's academic advisor may approve/reject; the department
|
||||
* leader signs but does not decide. Other staff roles retain override access.
|
||||
*/
|
||||
public function canReviewCourseRegistrationOf(Student $student): bool
|
||||
{
|
||||
if ($this->hasRole('dosen')) {
|
||||
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||
return $this->isAdvisorOf($student);
|
||||
}
|
||||
|
||||
if ($this->hasRole('kaprodi')) {
|
||||
if ($this->hasRole(UserRole::Kaprodi->value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -1,42 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\AcademicClasses;
|
||||
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||
|
||||
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'])])
|
||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
||||
->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) {
|
||||
$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('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) {
|
||||
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
||||
$q->whereHas('registrations', function ($q) use ($user) {
|
||||
$q->where('student_id', $user->student?->id)
|
||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||
});
|
||||
}))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
|
||||
}
|
||||
|
||||
public function create(array $data, ?UploadedFile $file): Assignment
|
||||
@ -53,17 +63,33 @@ public function create(array $data, ?UploadedFile $file): Assignment
|
||||
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
||||
}
|
||||
|
||||
$assignment->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->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();
|
||||
$assignment->update([
|
||||
'course_class_id' => $data['course_class_id'],
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'deadline' => $data['deadline'],
|
||||
'status' => $data['status'] ?? $assignment->status,
|
||||
]);
|
||||
|
||||
if ($file) {
|
||||
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\AcademicClasses;
|
||||
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||
|
||||
use App\Enums\SubmissionStatus;
|
||||
use App\Models\Assignment;
|
||||
@ -5,10 +5,8 @@
|
||||
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
|
||||
{
|
||||
@ -113,35 +111,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,29 +3,39 @@
|
||||
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 paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator
|
||||
public function __construct(
|
||||
private readonly NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
||||
{
|
||||
return Material::query()
|
||||
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number'])
|
||||
->with('courseClass.course:id,code,name')
|
||||
->with([
|
||||
'courseClass.course:id,code,name',
|
||||
'courseClass.lecturer.user.profile',
|
||||
])
|
||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
||||
->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) {
|
||||
$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);
|
||||
->paginate($perPage, ['id', 'course_class_id', 'title', 'description', 'meeting_number', 'created_at']);
|
||||
}
|
||||
|
||||
public function create(array $data, ?UploadedFile $file): Material
|
||||
@ -41,16 +51,32 @@ public function create(array $data, ?UploadedFile $file): Material
|
||||
$material->addMedia($file)->toMediaCollection('materials');
|
||||
}
|
||||
|
||||
$material->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->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();
|
||||
$material->update([
|
||||
'course_class_id' => $data['course_class_id'],
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'meeting_number' => $data['meeting_number'] ?? null,
|
||||
]);
|
||||
|
||||
if ($file) {
|
||||
$material->addMedia($file)->toMediaCollection('materials');
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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;
|
||||
@ -13,7 +14,6 @@ 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,39 +26,14 @@ 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('mahasiswa'), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
|
||||
$q->where('student_id', $user->student?->id)
|
||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||
}))
|
||||
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
|
||||
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
|
||||
})
|
||||
->orderBy('start_time')
|
||||
->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();
|
||||
->get(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link']);
|
||||
}
|
||||
|
||||
public function create(array $data): Schedule
|
||||
@ -75,13 +50,14 @@ public function create(array $data): Schedule
|
||||
|
||||
public function update(Schedule $schedule, array $data): Schedule
|
||||
{
|
||||
$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();
|
||||
$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,
|
||||
]);
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
@ -90,4 +66,28 @@ 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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,18 +41,10 @@ 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()
|
||||
->where('user_id', $user->id)
|
||||
->when(! $user->can('update-feedback-status'), fn ($q) => $q->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))
|
||||
@ -73,14 +65,20 @@ public function create(User $user, array $data): Feedback
|
||||
|
||||
public function update(Feedback $feedback, array $data): Feedback
|
||||
{
|
||||
$feedback->type = $data['type'];
|
||||
$feedback->subject = $data['subject'];
|
||||
$feedback->message = $this->sanitizer->sanitize($data['message']);
|
||||
$feedback->update();
|
||||
$feedback->update([
|
||||
'type' => $data['type'],
|
||||
'subject' => $data['subject'],
|
||||
'message' => $this->sanitizer->sanitize($data['message']),
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public function delete(Feedback $feedback): bool
|
||||
{
|
||||
return $feedback->delete();
|
||||
}
|
||||
|
||||
public function updateStatus(Feedback $feedback, string $status): Feedback
|
||||
{
|
||||
$feedback->update([
|
||||
@ -91,8 +89,26 @@ public function updateStatus(Feedback $feedback, string $status): Feedback
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public function delete(Feedback $feedback): bool
|
||||
public function reply(Feedback $feedback, string $reply): Feedback
|
||||
{
|
||||
return $feedback->delete();
|
||||
$feedback->update([
|
||||
'reply' => $this->sanitizer->sanitize($reply),
|
||||
'replied_at' => now(),
|
||||
'handled_by' => auth()->id(),
|
||||
'status' => FeedbackStatus::Resolved->value,
|
||||
]);
|
||||
|
||||
return $feedback;
|
||||
}
|
||||
|
||||
public function pendingCount(User $user): int
|
||||
{
|
||||
if (! $user->can('update-feedback-status')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Feedback::query()
|
||||
->where('status', FeedbackStatus::Submitted)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Announcement;
|
||||
use App\Models\Student;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
@ -13,15 +14,15 @@ public function __construct(
|
||||
private readonly NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||
public function paginated(User $user, int $perPage = 25, string $search = ''): LengthAwarePaginator
|
||||
{
|
||||
return Announcement::query()
|
||||
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at'])
|
||||
->with(['department:id,name', 'creator.profile'])
|
||||
->with(['creator.profile'])
|
||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Mahasiswa->value))
|
||||
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Dosen->value))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['id', 'title', 'content', 'target_roles', 'created_by', 'created_at']);
|
||||
}
|
||||
|
||||
public function create(array $data): Announcement
|
||||
@ -29,15 +30,13 @@ public function create(array $data): Announcement
|
||||
$announcement = Announcement::create([
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'],
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
||||
'target_roles' => $data['target_roles'],
|
||||
'created_by' => auth()->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');
|
||||
$recipientUserIds = User::query()
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', $announcement->target_roles))
|
||||
->pluck('id');
|
||||
|
||||
$this->notificationService->sendToUsers(
|
||||
$recipientUserIds,
|
||||
@ -51,11 +50,11 @@ public function create(array $data): Announcement
|
||||
|
||||
public function update(Announcement $announcement, array $data): Announcement
|
||||
{
|
||||
$announcement->title = $data['title'];
|
||||
$announcement->content = $data['content'];
|
||||
$announcement->department_id = $data['department_id'] ?? null;
|
||||
$announcement->enrollment_year = $data['enrollment_year'] ?? null;
|
||||
$announcement->update();
|
||||
$announcement->update([
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'],
|
||||
'target_roles' => $data['target_roles'],
|
||||
]);
|
||||
|
||||
return $announcement;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
namespace App\Services\Admin\Manage\CourseClass;
|
||||
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\CourseClass;
|
||||
@ -1,36 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
namespace App\Services\Admin\Manage\CourseClass;
|
||||
|
||||
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
|
||||
{
|
||||
/**
|
||||
* 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 __construct(
|
||||
private readonly AcademicTermService $academicTermService,
|
||||
) {}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
|
||||
{
|
||||
@ -40,7 +24,9 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
|
||||
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
||||
->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->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
|
||||
->when($search, fn ($q) => $q->where(fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%")
|
||||
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))))
|
||||
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
|
||||
->when($method, fn ($q) => $q->where('course_classes.method', $method))
|
||||
->orderByDesc('academic_terms.start_date')
|
||||
@ -65,11 +51,12 @@ public function createMany(array $data): Collection
|
||||
|
||||
public function update(CourseClass $courseClass, array $data): CourseClass
|
||||
{
|
||||
$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();
|
||||
$courseClass->update([
|
||||
'course_id' => $data['course_id'],
|
||||
'lecturer_id' => $data['lecturer_id'],
|
||||
'academic_term_id' => $data['academic_term_id'],
|
||||
'method' => $data['method'] ?? null,
|
||||
]);
|
||||
|
||||
return $courseClass;
|
||||
}
|
||||
@ -121,4 +108,29 @@ 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']);
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Enums\RegistrationStatus;
|
||||
use App\Enums\StudentStatus;
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\AcademicTerm;
|
||||
use App\Models\ClassEnrollment;
|
||||
use App\Models\Course;
|
||||
@ -12,6 +13,7 @@
|
||||
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;
|
||||
@ -24,24 +26,13 @@ public function __construct(
|
||||
private readonly AcademicTermService $academicTermService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Department>
|
||||
*/
|
||||
public function departmentSummary(): Collection
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||
{
|
||||
return Department::query()
|
||||
->select('id', 'name')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
$isDepartmentLeader = $user->hasRole(UserRole::Kaprodi->value);
|
||||
$advisorLecturerId = ! $isDepartmentLeader && $user->hasRole(UserRole::Dosen->value) ? $user->lecturer?->id : null;
|
||||
$ledDepartmentIds = $isDepartmentLeader ? $user->ledDepartmentIds() : null;
|
||||
|
||||
/**
|
||||
* @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')
|
||||
@ -54,7 +45,7 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
||||
->with(['user.profile', 'department:id,name'])
|
||||
->orderBy('departments.name')
|
||||
->orderBy('user_profiles.full_name')
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['students.id', 'students.user_id', 'students.student_number', 'students.department_id']);
|
||||
|
||||
return $paginator->through(fn (Student $student) => [
|
||||
'student_id' => $student->id,
|
||||
@ -219,11 +210,11 @@ public function saveRegistrations(
|
||||
});
|
||||
}
|
||||
|
||||
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
|
||||
public function signAsDepartmentLeader(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
|
||||
{
|
||||
$this->assertSignable($submission);
|
||||
|
||||
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature');
|
||||
$submission->addMedia($signature)->toMediaCollection('department_leader_signature');
|
||||
|
||||
return $submission;
|
||||
}
|
||||
@ -246,7 +237,7 @@ private function assertSignable(CourseRegistrationSubmission $submission): void
|
||||
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
|
||||
{
|
||||
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
|
||||
abort_if($submission->kaprodi_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
|
||||
abort_if($submission->department_leader_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
|
||||
abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.');
|
||||
|
||||
return DB::transaction(function () use ($submission) {
|
||||
@ -299,4 +290,14 @@ 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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,30 +9,14 @@
|
||||
|
||||
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()
|
||||
->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);
|
||||
->paginate($perPage, ['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
|
||||
}
|
||||
|
||||
public function create(array $data): AcademicTerm
|
||||
@ -45,13 +29,14 @@ public function create(array $data): AcademicTerm
|
||||
|
||||
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
|
||||
{
|
||||
$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();
|
||||
$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'] ?? []),
|
||||
]);
|
||||
|
||||
return $academicTerm;
|
||||
}
|
||||
@ -72,6 +57,21 @@ public function updateActiveStatus(AcademicTerm $academicTerm, bool $isActive):
|
||||
});
|
||||
}
|
||||
|
||||
public function getAllForSelect(): Collection
|
||||
{
|
||||
return AcademicTerm::latest()->get(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The academic term currently marked active, falling back to the one with
|
||||
* the latest start date if none is explicitly active.
|
||||
*/
|
||||
public function getActive(): ?AcademicTerm
|
||||
{
|
||||
return AcademicTerm::where('is_active', true)->first()
|
||||
?? AcademicTerm::orderByDesc('start_date')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $openSemesters
|
||||
* @return array<int, int>
|
||||
|
||||
@ -2,52 +2,28 @@
|
||||
|
||||
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 getAllForSelect(): Collection
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
|
||||
{
|
||||
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('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') ?? []))
|
||||
->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') ?? []))
|
||||
->orderBy('departments.name')
|
||||
->orderBy('courses.semester_number')
|
||||
->orderBy('courses.name')
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number']);
|
||||
}
|
||||
|
||||
public function create(array $data): Course
|
||||
@ -57,12 +33,13 @@ public function create(array $data): Course
|
||||
|
||||
public function update(Course $course, array $data): Course
|
||||
{
|
||||
$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();
|
||||
$course->update([
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'credits' => $data['credits'],
|
||||
'department_id' => $data['department_id'],
|
||||
'semester_number' => $data['semester_number'] ?? null,
|
||||
]);
|
||||
|
||||
return $course;
|
||||
}
|
||||
@ -71,4 +48,25 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Department;
|
||||
use App\Models\DepartmentLeadership;
|
||||
use App\Models\Lecturer;
|
||||
@ -10,19 +11,17 @@
|
||||
|
||||
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()
|
||||
->select(['id', 'code', 'name', 'degree_level'])
|
||||
->with(['currentLeader.lecturer.user.profile'])
|
||||
->with([
|
||||
'currentLeader.lecturer.user.profile',
|
||||
'leaderships' => fn ($q) => $q->orderByRaw('ended_at IS NULL DESC')->orderByDesc('started_at'),
|
||||
'leaderships.lecturer.user.profile',
|
||||
])
|
||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['id', 'code', 'name', 'degree_level']);
|
||||
}
|
||||
|
||||
public function create(array $data): Department
|
||||
@ -40,16 +39,27 @@ public function create(array $data): Department
|
||||
|
||||
public function update(Department $department, array $data): Department
|
||||
{
|
||||
$department->code = $data['code'];
|
||||
$department->name = $data['name'];
|
||||
$department->degree_level = $data['degree_level'] ?? null;
|
||||
$department->update();
|
||||
$department->update([
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'degree_level' => $data['degree_level'] ?? null,
|
||||
]);
|
||||
|
||||
$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()
|
||||
@ -90,8 +100,8 @@ private function grantKaprodiRole(int $lecturerId): void
|
||||
{
|
||||
$user = Lecturer::find($lecturerId)?->user;
|
||||
|
||||
if ($user && ! $user->hasRole('kaprodi')) {
|
||||
$user->assignRole('kaprodi');
|
||||
if ($user && ! $user->hasRole(UserRole::Kaprodi->value)) {
|
||||
$user->assignRole(UserRole::Kaprodi->value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,12 +113,7 @@ private function revokeKaprodiRoleIfNoLongerLeading(int $lecturerId): void
|
||||
->exists();
|
||||
|
||||
if (! $stillLeadsAnyDepartment) {
|
||||
Lecturer::find($lecturerId)?->user?->removeRole('kaprodi');
|
||||
Lecturer::find($lecturerId)?->user?->removeRole(UserRole::Kaprodi->value);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Department $department): bool
|
||||
{
|
||||
return $department->delete();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,18 +2,22 @@
|
||||
|
||||
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(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return AcademicAdvisingLog::query()
|
||||
->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',
|
||||
@ -23,9 +27,10 @@ public function paginated(int $perPage = 25, string $search = '', ?int $lecturer
|
||||
->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
||||
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
||||
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
|
||||
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
|
||||
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('students', fn ($q) => $q->where('students.id', $user->student?->id)))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['id', 'lecturer_id', 'topic', 'notes', 'created_at']);
|
||||
}
|
||||
|
||||
public function create(array $data): AcademicAdvisingLog
|
||||
@ -43,10 +48,11 @@ public function create(array $data): AcademicAdvisingLog
|
||||
|
||||
public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
|
||||
{
|
||||
$log->lecturer_id = $data['lecturer_id'];
|
||||
$log->topic = $data['topic'] ?? null;
|
||||
$log->notes = $data['notes'] ?? null;
|
||||
$log->update();
|
||||
$log->update([
|
||||
'lecturer_id' => $data['lecturer_id'],
|
||||
'topic' => $data['topic'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$log->students()->sync($data['student_ids']);
|
||||
|
||||
|
||||
@ -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(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return LetterRequest::query()
|
||||
->select(['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at'])
|
||||
->with(['user.profile', 'user.student.department', 'processor.profile'])
|
||||
->with(['user.profile', 'user.student.department', 'user.lecturer.departments', '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('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"))
|
||||
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"))))
|
||||
->when($status, fn ($q) => $q->where('status', $status))
|
||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('user_id', $user->id))
|
||||
->when($user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('user_id', $user->id))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at']);
|
||||
}
|
||||
|
||||
public function create(array $data): LetterRequest
|
||||
@ -38,9 +38,10 @@ public function create(array $data): LetterRequest
|
||||
|
||||
public function update(LetterRequest $letterRequest, array $data): LetterRequest
|
||||
{
|
||||
$letterRequest->letter_type = $data['letter_type'];
|
||||
$letterRequest->purpose = $data['purpose'] ?? null;
|
||||
$letterRequest->update();
|
||||
$letterRequest->update([
|
||||
'letter_type' => $data['letter_type'],
|
||||
'purpose' => $data['purpose'] ?? null,
|
||||
]);
|
||||
|
||||
return $letterRequest;
|
||||
}
|
||||
@ -54,12 +55,13 @@ public function updateStatus(LetterRequest $letterRequest, ?string $status, ?Upl
|
||||
{
|
||||
$status = $result ? LetterStatus::Completed->value : $status;
|
||||
|
||||
$letterRequest->status = $status;
|
||||
$letterRequest->processed_by = $status !== LetterStatus::Submitted->value ? auth()->id() : null;
|
||||
$letterRequest->completed_at = $status === LetterStatus::Completed->value
|
||||
$letterRequest->update([
|
||||
'status' => $status,
|
||||
'processed_by' => $status !== LetterStatus::Submitted->value ? auth()->id() : null,
|
||||
'completed_at' => $status === LetterStatus::Completed->value
|
||||
? ($letterRequest->completed_at ?? now())
|
||||
: null;
|
||||
$letterRequest->update();
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($result) {
|
||||
$letterRequest->addMedia($result)->toMediaCollection('letter_result');
|
||||
|
||||
@ -2,21 +2,28 @@
|
||||
|
||||
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::select(['id', 'username', 'email', 'is_active'])
|
||||
->with([
|
||||
return User::with([
|
||||
'profile:id,user_id,full_name,phone_number,gender',
|
||||
'roles:id,name',
|
||||
])
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan']))
|
||||
->whereHas('roles', fn ($q) => $q->whereIn('name', array_column(self::ROLES, 'value')))
|
||||
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
||||
$query->where('username', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%")
|
||||
@ -24,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', ?string $gende
|
||||
}))
|
||||
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||
}
|
||||
|
||||
public function create(array $data): User
|
||||
@ -93,4 +100,12 @@ 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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Admin\Users;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Models\Lecturer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@ -12,51 +13,11 @@
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)));
|
||||
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||
}
|
||||
|
||||
public function create(array $data): User
|
||||
@ -68,7 +29,7 @@ public function create(array $data): User
|
||||
'password' => Hash::make(config('app.default_password')),
|
||||
]);
|
||||
|
||||
$user->assignRole('dosen');
|
||||
$user->assignRole(UserRole::Dosen->value);
|
||||
|
||||
$user->profile()->create([
|
||||
'full_name' => $data['full_name'],
|
||||
@ -136,4 +97,42 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
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;
|
||||
@ -12,67 +14,11 @@
|
||||
|
||||
class StudentService
|
||||
{
|
||||
public function getAllForSelect(?string $status = null): Collection
|
||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
|
||||
{
|
||||
return Student::select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester'])
|
||||
->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)
|
||||
return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
|
||||
->latest()
|
||||
->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)));
|
||||
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||
}
|
||||
|
||||
public function create(array $data): User
|
||||
@ -84,7 +30,7 @@ public function create(array $data): User
|
||||
'password' => Hash::make(config('app.default_password')),
|
||||
]);
|
||||
|
||||
$user->assignRole('mahasiswa');
|
||||
$user->assignRole(UserRole::Mahasiswa->value);
|
||||
|
||||
$user->profile()->create([
|
||||
'full_name' => $data['full_name'],
|
||||
@ -101,7 +47,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' => 'active',
|
||||
'status' => StudentStatus::Active,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
@ -162,4 +108,62 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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-own-attendances',
|
||||
'view-attendances', 'create-attendances', 'delete-attendances',
|
||||
];
|
||||
|
||||
public const MANAGE = [
|
||||
@ -50,7 +50,7 @@ class PermissionCatalog
|
||||
];
|
||||
|
||||
public const FEEDBACK = [
|
||||
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status',
|
||||
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'reply-feedback',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -12,8 +12,7 @@ public function up(): void
|
||||
$table->id();
|
||||
$table->string('title', 200);
|
||||
$table->text('content');
|
||||
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->integer('enrollment_year')->nullable();
|
||||
$table->json('target_roles');
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
@ -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('admin_notes')->nullable();
|
||||
$table->text('reply')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@ -13,8 +13,7 @@ public function run(): void
|
||||
[
|
||||
'title' => 'Libur Idul Fitri',
|
||||
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
|
||||
'department_id' => null,
|
||||
'enrollment_year' => null,
|
||||
'target_roles' => json_encode(['mahasiswa', 'dosen']),
|
||||
'created_by' => 5,
|
||||
'created_at' => '2026-03-01 08:00:00',
|
||||
'updated_at' => '2026-03-01 08:00:00',
|
||||
@ -22,8 +21,7 @@ public function run(): void
|
||||
[
|
||||
'title' => 'Jadwal UTS Prodi SI',
|
||||
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
|
||||
'department_id' => 1,
|
||||
'enrollment_year' => null,
|
||||
'target_roles' => json_encode(['mahasiswa']),
|
||||
'created_by' => 5,
|
||||
'created_at' => '2026-10-01 08:00:00',
|
||||
'updated_at' => '2026-10-01 08:00:00',
|
||||
|
||||
@ -40,6 +40,7 @@ public function run(): void
|
||||
'mahasiswa' => [
|
||||
'view-dashboard',
|
||||
'view-academic-terms',
|
||||
'view-academic-advising-logs',
|
||||
'view-courses',
|
||||
'view-course-registrations',
|
||||
'view-letter-requests',
|
||||
@ -50,7 +51,7 @@ public function run(): void
|
||||
'view-materials',
|
||||
'view-assignments',
|
||||
'submit-assignments',
|
||||
'view-own-attendances',
|
||||
'view-announcements',
|
||||
...$feedbackSelfService,
|
||||
],
|
||||
'dosen' => [
|
||||
@ -75,6 +76,11 @@ 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' => [
|
||||
@ -84,8 +90,9 @@ public function run(): void
|
||||
...$manage,
|
||||
...array_diff($services, ['create-letter-requests', 'update-letter-requests']),
|
||||
...$users,
|
||||
...$feedbackSelfService,
|
||||
'view-feedback',
|
||||
'update-feedback-status',
|
||||
'reply-feedback',
|
||||
],
|
||||
'staff-keuangan' => [
|
||||
'view-dashboard',
|
||||
|
||||
@ -36,10 +36,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments';
|
||||
import {
|
||||
index as attendancesRoute,
|
||||
mine as myAttendancesRoute,
|
||||
} from '@/routes/admin/academic-classes/attendances';
|
||||
import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances';
|
||||
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
|
||||
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
|
||||
import { index as logsRoute } from '@/routes/admin/developer/logs';
|
||||
@ -175,15 +172,6 @@ function buildNavMain({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can('view-own-attendances')
|
||||
? [
|
||||
{
|
||||
name: 'Riwayat Kehadiran',
|
||||
url: myAttendancesRoute.url(),
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const keuanganItems: NavItem[] = [
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import { Eye, 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'];
|
||||
@ -19,12 +25,14 @@ 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);
|
||||
@ -34,6 +42,22 @@ 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)}
|
||||
@ -45,6 +69,7 @@ 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
|
||||
|
||||
228
resources/js/components/data-cards.tsx
Normal file
228
resources/js/components/data-cards.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import { InfiniteScroll } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { useDebounce } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export type { PaginationState } from '@/components/data-table';
|
||||
|
||||
interface DataCardsProps<TData> {
|
||||
data: TData[];
|
||||
renderCard: (item: TData, index: number) => React.ReactNode;
|
||||
getRowId?: (item: TData) => string | number;
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
toolbar?: React.ReactNode;
|
||||
pagination: PaginationState;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPerPageChange?: (perPage: number) => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
searchValue?: string;
|
||||
/**
|
||||
* Enables infinite scroll instead of page-based pagination. `propName`
|
||||
* must match the Inertia page prop returned via `Inertia::scroll()` on
|
||||
* the backend so the frontend can track its merge/scroll metadata.
|
||||
*/
|
||||
infiniteScroll?: {
|
||||
propName: string;
|
||||
buffer?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function DataCards<TData>({
|
||||
data,
|
||||
renderCard,
|
||||
getRowId,
|
||||
searchKey,
|
||||
searchPlaceholder = 'Ketikkan sesuatu...',
|
||||
emptyText = 'Tidak ada data.',
|
||||
toolbar,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPerPageChange,
|
||||
onSearchChange,
|
||||
searchValue,
|
||||
infiniteScroll,
|
||||
}: DataCardsProps<TData>) {
|
||||
const isInfiniteScroll = !!infiniteScroll;
|
||||
const [localSearch, setLocalSearch] = React.useState(searchValue ?? '');
|
||||
const [previousSearchValue, setPreviousSearchValue] =
|
||||
React.useState(searchValue);
|
||||
|
||||
if (searchValue !== previousSearchValue) {
|
||||
setPreviousSearchValue(searchValue);
|
||||
setLocalSearch(searchValue ?? '');
|
||||
}
|
||||
|
||||
const handleSearchDebounced = useDebounce(
|
||||
(value: string) => onSearchChange?.(value),
|
||||
300,
|
||||
);
|
||||
|
||||
function handleSearchChange(value: string) {
|
||||
setLocalSearch(value);
|
||||
handleSearchDebounced(value);
|
||||
}
|
||||
|
||||
const currentPage = pagination.current_page;
|
||||
const totalPages = pagination.last_page;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(searchKey || toolbar || onPerPageChange) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{searchKey && (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={localSearch}
|
||||
onChange={(event) =>
|
||||
handleSearchChange(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)}
|
||||
{toolbar}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{onPerPageChange && (
|
||||
<Select
|
||||
value={String(pagination.per_page)}
|
||||
onValueChange={(value) =>
|
||||
onPerPageChange(Number(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="25">25</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="999999">
|
||||
Semua
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.length ? (
|
||||
isInfiniteScroll ? (
|
||||
<InfiniteScroll
|
||||
data={infiniteScroll.propName}
|
||||
as="div"
|
||||
onlyNext
|
||||
preserveUrl
|
||||
buffer={infiniteScroll.buffer ?? 300}
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
next={({ loading, hasMore }) =>
|
||||
loading ? (
|
||||
<p className="py-2 text-center text-sm text-muted-foreground">
|
||||
Memuat data...
|
||||
</p>
|
||||
) : hasMore ? null : (
|
||||
<p className="py-2 text-center text-sm text-muted-foreground">
|
||||
Semua data telah ditampilkan.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<Card
|
||||
key={getRowId ? getRowId(item) : index}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<CardContent>
|
||||
{renderCard(item, index)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{data.map((item, index) => (
|
||||
<Card
|
||||
key={getRowId ? getRowId(item) : index}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<CardContent>
|
||||
{renderCard(item, index)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex h-24 items-center justify-center text-center text-muted-foreground">
|
||||
{emptyText}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isInfiniteScroll ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
|
||||
{pagination.total.toLocaleString('id-ID')} data
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{`Halaman ${currentPage} dari ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange?.(totalPages)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ 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,
|
||||
@ -71,6 +72,15 @@ 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<{
|
||||
@ -129,7 +139,7 @@ function SortableTableRow({
|
||||
);
|
||||
}
|
||||
|
||||
function useDebounce(callback: (value: string) => void, delay: number) {
|
||||
export function useDebounce(callback: (value: string) => void, delay: number) {
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
return React.useCallback(
|
||||
@ -163,7 +173,10 @@ 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 {};
|
||||
@ -183,7 +196,9 @@ export function DataTable<TData, TValue>({
|
||||
setLocalSearch(searchValue ?? '');
|
||||
}, [searchValue]);
|
||||
|
||||
const isServerMode = !!pagination && !!onPageChange;
|
||||
const isInfiniteScroll = !!infiniteScroll;
|
||||
const hasServerPager = !isInfiniteScroll && !!pagination && !!onPageChange;
|
||||
const hasServerSearch = !!onSearchChange;
|
||||
const isSortable = !!onReorder && !!getRowId;
|
||||
|
||||
const handleSearchDebounced = useDebounce(
|
||||
@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{isServerMode
|
||||
{hasServerPager
|
||||
? (currentPage - 1) * (pagination?.per_page ?? 25) +
|
||||
row.index +
|
||||
1
|
||||
@ -275,14 +290,14 @@ export function DataTable<TData, TValue>({
|
||||
function handleSearchChange(value: string) {
|
||||
setLocalSearch(value);
|
||||
|
||||
if (isServerMode) {
|
||||
if (hasServerSearch) {
|
||||
handleSearchDebounced(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(searchKey || toolbar || isServerMode) && (
|
||||
{(searchKey || toolbar || hasServerPager || isInfiniteScroll) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{searchKey && (
|
||||
<Input
|
||||
@ -296,7 +311,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
{toolbar}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isServerMode && onPerPageChange && (
|
||||
{hasServerPager && onPerPageChange && (
|
||||
<Select
|
||||
value={String(pagination?.per_page ?? 25)}
|
||||
onValueChange={(value) =>
|
||||
@ -349,7 +364,7 @@ export function DataTable<TData, TValue>({
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody ref={tbodyRef}>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
isSortable ? (
|
||||
<DndContext
|
||||
@ -407,8 +422,7 @@ export function DataTable<TData, TValue>({
|
||||
</DndContext>
|
||||
) : (
|
||||
(() => {
|
||||
let previousGroup: string | null =
|
||||
null;
|
||||
let previousGroup: string | null = null;
|
||||
|
||||
return table
|
||||
.getRowModel()
|
||||
@ -445,8 +459,7 @@ export function DataTable<TData, TValue>({
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map(
|
||||
(cell) => (
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
@ -471,8 +484,7 @@ export function DataTable<TData, TValue>({
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
),
|
||||
)}
|
||||
))}
|
||||
</TableRow>
|
||||
{renderSubRow &&
|
||||
row.getIsExpanded() && (
|
||||
@ -512,14 +524,43 @@ 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">
|
||||
{isServerMode
|
||||
{hasServerPager
|
||||
? `Halaman ${currentPage} dari ${totalPages}`
|
||||
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isServerMode ? (
|
||||
{hasServerPager ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -532,7 +573,9 @@ 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" />
|
||||
@ -540,7 +583,9 @@ 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" />
|
||||
@ -584,7 +629,9 @@ export function DataTable<TData, TValue>({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
table.setPageIndex(table.getPageCount() - 1)
|
||||
table.setPageIndex(
|
||||
table.getPageCount() - 1,
|
||||
)
|
||||
}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@ -594,6 +641,7 @@ export function DataTable<TData, TValue>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,7 +13,58 @@ import {
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
function isGroupedItems(
|
||||
items: readonly unknown[]
|
||||
): items is ReadonlyArray<{ items: readonly unknown[] }> {
|
||||
return (
|
||||
items.length > 0 &&
|
||||
typeof items[0] === "object" &&
|
||||
items[0] !== null &&
|
||||
"items" in items[0]
|
||||
)
|
||||
}
|
||||
|
||||
function Combobox<Value, Multiple extends boolean | undefined = false>({
|
||||
items,
|
||||
multiple,
|
||||
value,
|
||||
isItemEqualToValue,
|
||||
...props
|
||||
}: ComboboxPrimitive.Root.Props<Value, Multiple>) {
|
||||
// In multi-select mode, once an option is chosen it's represented as a
|
||||
// chip, so it's removed from the dropdown list instead of staying there
|
||||
// with a checkmark.
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!multiple || !items) return items
|
||||
|
||||
const selected = value as unknown as Value[] | null | undefined
|
||||
if (!Array.isArray(selected) || selected.length === 0) return items
|
||||
|
||||
const isEqual =
|
||||
isItemEqualToValue ?? ((a: Value, b: Value) => Object.is(a, b))
|
||||
const isNotSelected = (item: Value) =>
|
||||
!selected.some((selectedValue) => isEqual(item, selectedValue))
|
||||
|
||||
if (isGroupedItems(items)) {
|
||||
return items.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => isNotSelected(item as Value)),
|
||||
}))
|
||||
}
|
||||
|
||||
return (items as readonly Value[]).filter(isNotSelected)
|
||||
}, [items, multiple, value, isItemEqualToValue])
|
||||
|
||||
return (
|
||||
<ComboboxPrimitive.Root
|
||||
items={filteredItems}
|
||||
multiple={multiple}
|
||||
value={value}
|
||||
isItemEqualToValue={isItemEqualToValue}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
|
||||
31
resources/js/components/view-toggle.tsx
Normal file
31
resources/js/components/view-toggle.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { LayoutGrid, LayoutList } from 'lucide-react';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
|
||||
export type ViewMode = 'table' | 'card';
|
||||
|
||||
type ViewToggleProps = {
|
||||
value: ViewMode;
|
||||
onChange: (value: ViewMode) => void;
|
||||
};
|
||||
|
||||
export function ViewToggle({ value, onChange }: ViewToggleProps) {
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
if (next) {
|
||||
onChange(next as ViewMode);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="table" aria-label="Tampilan tabel">
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="card" aria-label="Tampilan kartu">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal file
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Clock,
|
||||
ClipboardList,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Upload,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { Assignment } from '@/types/assignment';
|
||||
import { AssignmentStatusLabels } from '@/types/assignment';
|
||||
import { deadlineTextClass, percentageOf } from './utils';
|
||||
|
||||
type CreateCardParams = {
|
||||
handleEdit: (assignment: Assignment) => void;
|
||||
handleDeleteClick: (assignment: Assignment) => void;
|
||||
handleSubmitClick: (assignment: Assignment) => void;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canSubmit: boolean;
|
||||
canViewSubmissions: boolean;
|
||||
};
|
||||
|
||||
export function createAssignmentCard(params: CreateCardParams) {
|
||||
const {
|
||||
handleEdit,
|
||||
handleDeleteClick,
|
||||
handleSubmitClick,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canSubmit,
|
||||
canViewSubmissions,
|
||||
} = params;
|
||||
|
||||
return function AssignmentCard(assignment: Assignment) {
|
||||
const mySubmission = assignment.submissions?.[0];
|
||||
const lecturerName =
|
||||
assignment.course_class?.lecturer?.user?.profile?.full_name ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-base leading-tight font-medium">
|
||||
{assignment.title}
|
||||
</p>
|
||||
{assignment.course_class && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{assignment.course_class.course?.code ?? ''}{' '}
|
||||
{assignment.course_class.course?.name ?? ''}
|
||||
</span>
|
||||
)}
|
||||
{lecturerName && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3 shrink-0" />
|
||||
{lecturerName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RowActions
|
||||
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
|
||||
actions={[
|
||||
{
|
||||
label:
|
||||
mySubmission?.status === 'submitted'
|
||||
? 'Kumpulkan Ulang'
|
||||
: 'Kumpulkan Tugas',
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
show:
|
||||
canSubmit && assignment.status === 'open',
|
||||
onClick: () => handleSubmitClick(assignment),
|
||||
},
|
||||
{
|
||||
label: 'Pengumpulan',
|
||||
icon: (
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
),
|
||||
show: canViewSubmissions,
|
||||
href: submissionsIndex.url(assignment.id),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
show: canUpdate,
|
||||
onClick: () => handleEdit(assignment),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
),
|
||||
show: canDelete,
|
||||
onClick: () => handleDeleteClick(assignment),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant={
|
||||
assignment.status === 'open'
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
className="w-fit text-[10px] font-normal"
|
||||
>
|
||||
{AssignmentStatusLabels[assignment.status]}
|
||||
</Badge>
|
||||
{assignment.course_class?.academic_term && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-fit text-[10px] font-normal"
|
||||
>
|
||||
{formatAcademicTermLabel(
|
||||
assignment.course_class.academic_term,
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs ${deadlineTextClass(assignment.deadline)}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
{format(new Date(assignment.deadline), 'd MMM yyyy, HH:mm')}
|
||||
</span>
|
||||
|
||||
{assignment.attachment_url && assignment.attachment_name ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<AttachmentPreviewDialog
|
||||
fileUrl={assignment.attachment_url}
|
||||
fileName={assignment.attachment_name}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{canSubmit ? (
|
||||
<Badge
|
||||
variant={
|
||||
mySubmission?.status === 'submitted'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{mySubmission?.status === 'submitted'
|
||||
? 'Sudah Mengumpulkan'
|
||||
: 'Belum Mengumpulkan'}
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary">
|
||||
{assignment.submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{(
|
||||
assignment.course_class
|
||||
?.enrollments_count ?? 0
|
||||
).toLocaleString('id-ID')}{' '}
|
||||
Pengumpulan (
|
||||
{percentageOf(
|
||||
assignment.submissions_count,
|
||||
assignment.course_class
|
||||
?.enrollments_count ?? 0,
|
||||
)}
|
||||
%)
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{assignment.graded_submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{assignment.submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
Dinilai (
|
||||
{percentageOf(
|
||||
assignment.graded_submissions_count,
|
||||
assignment.submissions_count,
|
||||
)}
|
||||
%)
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assignment.description && (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="-mx-6 -mb-6 border-t"
|
||||
>
|
||||
<AccordionItem
|
||||
value="description"
|
||||
className="border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
|
||||
Deskripsi
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6">
|
||||
<p className="text-sm whitespace-pre-line text-foreground">
|
||||
{assignment.description}
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,213 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
import { ClipboardList, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
||||
import type { Assignment } from '@/types/assignment';
|
||||
import { AssignmentStatusLabels } from '@/types/assignment';
|
||||
import { deadlineTextClass, percentageOf } from './utils';
|
||||
|
||||
export type { Assignment } from '@/types/assignment';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (assignment: Assignment) => void;
|
||||
handleDeleteClick: (assignment: Assignment) => void;
|
||||
handleSubmitClick: (assignment: Assignment) => void;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canSubmit: boolean;
|
||||
canViewSubmissions: boolean;
|
||||
};
|
||||
|
||||
export function createAssignmentColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Assignment>[] {
|
||||
const {
|
||||
handleEdit,
|
||||
handleDeleteClick,
|
||||
handleSubmitClick,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canSubmit,
|
||||
canViewSubmissions,
|
||||
} = params;
|
||||
|
||||
const columns: ColumnDef<Assignment>[] = [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => <span>Judul</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.getValue('title') as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'course_class.course.name',
|
||||
header: () => <span>Kelas</span>,
|
||||
cell: ({ row }) => {
|
||||
const courseClass = row.original.course_class;
|
||||
|
||||
if (!courseClass) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deadline',
|
||||
header: () => <span>Batas Waktu</span>,
|
||||
cell: ({ row }) => {
|
||||
const assignment = row.original;
|
||||
|
||||
return (
|
||||
<span className={deadlineTextClass(assignment.deadline)}>
|
||||
{format(
|
||||
new Date(assignment.deadline),
|
||||
'd MMM yyyy, HH:mm',
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: () => <span className="block text-center">Status</span>,
|
||||
meta: {
|
||||
className: 'text-center',
|
||||
headerClassName: 'text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const assignment = row.original;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
assignment.status === 'open'
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
>
|
||||
{AssignmentStatusLabels[assignment.status]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'submissions',
|
||||
header: () => <span>Pengumpulan</span>,
|
||||
cell: ({ row }) => {
|
||||
const assignment = row.original;
|
||||
|
||||
if (canSubmit) {
|
||||
const mySubmission = assignment.submissions?.[0];
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
mySubmission?.status === 'submitted'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{mySubmission?.status === 'submitted'
|
||||
? 'Sudah Mengumpulkan'
|
||||
: 'Belum Mengumpulkan'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const enrollmentsCount =
|
||||
assignment.course_class?.enrollments_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{assignment.submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
/ {enrollmentsCount.toLocaleString('id-ID')}{' '}
|
||||
mengumpulkan (
|
||||
{percentageOf(
|
||||
assignment.submissions_count,
|
||||
enrollmentsCount,
|
||||
)}
|
||||
%)
|
||||
</span>
|
||||
<span>
|
||||
{assignment.graded_submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{assignment.submissions_count.toLocaleString(
|
||||
'id-ID',
|
||||
)}{' '}
|
||||
dinilai (
|
||||
{percentageOf(
|
||||
assignment.graded_submissions_count,
|
||||
assignment.submissions_count,
|
||||
)}
|
||||
%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[160px] text-center',
|
||||
headerClassName: 'w-[160px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const assignment = row.original;
|
||||
const mySubmission = assignment.submissions?.[0];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label:
|
||||
mySubmission?.status === 'submitted'
|
||||
? 'Kumpulkan Ulang'
|
||||
: 'Kumpulkan Tugas',
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
show:
|
||||
canSubmit && assignment.status === 'open',
|
||||
onClick: () => handleSubmitClick(assignment),
|
||||
},
|
||||
{
|
||||
label: 'Pengumpulan',
|
||||
icon: <ClipboardList className="h-4 w-4" />,
|
||||
show: canViewSubmissions,
|
||||
href: submissionsIndex.url(assignment.id),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
show: canUpdate,
|
||||
onClick: () => handleEdit(assignment),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
show: canDelete,
|
||||
onClick: () => handleDeleteClick(assignment),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return columns;
|
||||
}
|
||||
@ -1,37 +1,18 @@
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Clock,
|
||||
ClipboardList,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
||||
import { DataCards } from '@/components/data-cards';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DateTimeField } from '@/components/datetime-field';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import { FileUploadField } from '@/components/file-upload-field';
|
||||
import type {
|
||||
FilterField,
|
||||
FilterOptionGroup,
|
||||
} from '@/components/filter-dialog';
|
||||
import type { 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,
|
||||
@ -53,6 +34,8 @@ 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 {
|
||||
@ -62,10 +45,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 { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment';
|
||||
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
|
||||
import { createAssignmentCard } from './card';
|
||||
import { createAssignmentColumns } from './columns';
|
||||
|
||||
type CourseClassOption = {
|
||||
id: number;
|
||||
@ -80,12 +63,6 @@ type CourseClassOption = {
|
||||
|
||||
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
||||
|
||||
type AcademicTermOption = {
|
||||
id: number;
|
||||
academic_year: string;
|
||||
semester: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
assignments: {
|
||||
data: Assignment[];
|
||||
@ -95,11 +72,9 @@ type Props = {
|
||||
total: number;
|
||||
};
|
||||
courseClasses: CourseClassOption[];
|
||||
academicTerms: AcademicTermOption[];
|
||||
highlight?: number;
|
||||
filters: {
|
||||
course_class_id?: string;
|
||||
academic_term_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ -107,29 +82,6 @@ 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[] {
|
||||
@ -209,7 +161,6 @@ function CourseClassField({
|
||||
export default function AssignmentIndex({
|
||||
assignments,
|
||||
courseClasses,
|
||||
academicTerms,
|
||||
highlight,
|
||||
filters,
|
||||
}: Props) {
|
||||
@ -217,6 +168,7 @@ 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');
|
||||
@ -224,15 +176,7 @@ export default function AssignmentIndex({
|
||||
const canViewSubmissions = hasPermission('view-assignment-submissions');
|
||||
const canSubmit = hasPermission('submit-assignments');
|
||||
|
||||
const filterFields: FilterField[] = [
|
||||
{
|
||||
key: 'academic_term_id',
|
||||
label: 'Periode Akademik',
|
||||
options: academicTerms.map((term) => ({
|
||||
value: String(term.id),
|
||||
label: formatAcademicTermLabel(term),
|
||||
})),
|
||||
},
|
||||
const filterFields = [
|
||||
{
|
||||
key: 'course_class_id',
|
||||
label: 'Kelas',
|
||||
@ -241,7 +185,7 @@ export default function AssignmentIndex({
|
||||
},
|
||||
];
|
||||
|
||||
const pagination = {
|
||||
const pagination: PaginationState = {
|
||||
current_page: assignments.current_page,
|
||||
last_page: assignments.last_page,
|
||||
per_page: assignments.per_page,
|
||||
@ -255,19 +199,6 @@ 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;
|
||||
@ -278,6 +209,37 @@ export default function AssignmentIndex({
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createAssignmentColumns({
|
||||
handleEdit: (assignment) => setEditing(assignment),
|
||||
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canSubmit,
|
||||
canViewSubmissions,
|
||||
});
|
||||
|
||||
const renderAssignmentCard = createAssignmentCard({
|
||||
handleEdit: (assignment) => setEditing(assignment),
|
||||
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canSubmit,
|
||||
canViewSubmissions,
|
||||
});
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<FilterDialog
|
||||
fields={filterFields}
|
||||
activeFilters={filters}
|
||||
onApply={applyFilters}
|
||||
/>
|
||||
<ViewToggle value={view} onChange={setView} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tugas" />
|
||||
@ -351,256 +313,29 @@ export default function AssignmentIndex({
|
||||
assignment={submitting}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Cari judul tugas..."
|
||||
value={search}
|
||||
onChange={(event) =>
|
||||
handleSearchChange(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
{view === 'table' ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={assignments.data}
|
||||
searchKey="title"
|
||||
pagination={pagination}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
infiniteScroll={{ propName: 'assignments' }}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
<FilterDialog
|
||||
fields={filterFields}
|
||||
activeFilters={filters}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{assignments.data.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada tugas.
|
||||
</p>
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
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),
|
||||
},
|
||||
]}
|
||||
<DataCards
|
||||
data={assignments.data}
|
||||
renderCard={renderAssignmentCard}
|
||||
getRowId={(assignment) => assignment.id}
|
||||
searchKey="title"
|
||||
pagination={pagination}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchValue={search}
|
||||
infiniteScroll={{ propName: 'assignments' }}
|
||||
toolbar={toolbar}
|
||||
/>
|
||||
</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
|
||||
|
||||
@ -94,15 +94,17 @@ export default function SubmissionIndex({ assignment, submissions }: Props) {
|
||||
|
||||
const columns: ColumnDef<Submission>[] = [
|
||||
{
|
||||
accessorKey: 'student.student_number',
|
||||
id: 'identity',
|
||||
header: () => <span>NIM</span>,
|
||||
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 ?? '-',
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
export function percentageOf(part: number, total: number): number {
|
||||
return total > 0 ? Math.round((part / total) * 100) : 0;
|
||||
}
|
||||
|
||||
/** Turns red as the deadline passes, amber once it's within 3 days. */
|
||||
export function deadlineTextClass(deadline: string): string {
|
||||
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
|
||||
|
||||
if (hoursLeft <= 0) {
|
||||
return 'font-medium text-destructive';
|
||||
}
|
||||
|
||||
if (hoursLeft <= 24) {
|
||||
return 'text-destructive';
|
||||
}
|
||||
|
||||
if (hoursLeft <= 72) {
|
||||
return 'text-amber-600 dark:text-amber-500';
|
||||
}
|
||||
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
@ -1,150 +0,0 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||
import type { AttendanceClassSummary } from '@/types/attendance';
|
||||
import { AttendanceStatusLabels } from '@/types/attendance';
|
||||
|
||||
type Props = {
|
||||
summaries: AttendanceClassSummary[];
|
||||
};
|
||||
|
||||
function courseClassLabel(
|
||||
courseClass: AttendanceClassSummary['course_class'],
|
||||
): string {
|
||||
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
|
||||
}
|
||||
|
||||
function percentageOf(part: number, total: number): number {
|
||||
return total > 0 ? Math.round((part / total) * 100) : 0;
|
||||
}
|
||||
|
||||
export default function MyAttendance({ summaries }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Riwayat Kehadiran" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<PageHeader title="Riwayat Kehadiran" />
|
||||
|
||||
{summaries.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
Belum ada kelas yang terdaftar.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{summaries.map((summary) => (
|
||||
<Card key={summary.course_class.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base leading-tight">
|
||||
{courseClassLabel(summary.course_class)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant={
|
||||
percentageOf(
|
||||
summary.present_count,
|
||||
summary.total_count,
|
||||
) >= 75
|
||||
? 'secondary'
|
||||
: 'destructive'
|
||||
}
|
||||
className="w-fit text-[10px] font-normal"
|
||||
>
|
||||
Hadir {summary.present_count} /{' '}
|
||||
{summary.total_count} (
|
||||
{percentageOf(
|
||||
summary.present_count,
|
||||
summary.total_count,
|
||||
)}
|
||||
%)
|
||||
</Badge>
|
||||
{summary.course_class.academic_term && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-fit text-[10px] font-normal"
|
||||
>
|
||||
{formatAcademicTermLabel(
|
||||
summary.course_class
|
||||
.academic_term,
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary.records.length > 0 && (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="-mx-6 -mb-6 border-t"
|
||||
>
|
||||
<AccordionItem
|
||||
value="records"
|
||||
className="border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
|
||||
Detail Pertemuan
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6">
|
||||
<div className="flex flex-col divide-y">
|
||||
{summary.records.map(
|
||||
(record) => (
|
||||
<div
|
||||
key={`${record.course_class_id}-${record.meeting_number}`}
|
||||
className="flex items-center justify-between gap-2 py-2 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
Pertemuan
|
||||
ke-
|
||||
{record.meeting_number ??
|
||||
'-'}{' '}
|
||||
·{' '}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
resources/js/pages/admin/academic-classes/materials/card.tsx
Normal file
133
resources/js/pages/admin/academic-classes/materials/card.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
FileIcon,
|
||||
FileX,
|
||||
Pencil,
|
||||
Trash2,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from '@/components/ui/attachment';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Material } from '@/types/material';
|
||||
|
||||
type CreateCardParams = {
|
||||
handleEdit: (material: Material) => void;
|
||||
handleDeleteClick: (material: Material) => void;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
};
|
||||
|
||||
export function createMaterialCard(params: CreateCardParams) {
|
||||
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
|
||||
|
||||
return function MaterialCard(material: Material) {
|
||||
const courseClass = material.course_class;
|
||||
const lecturerName =
|
||||
courseClass?.lecturer?.user?.profile?.full_name ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium break-words">
|
||||
{material.title}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{courseClass
|
||||
? `${courseClass.course?.code ?? ''} · ${courseClass.course?.name ?? ''}`
|
||||
: '-'}
|
||||
</p>
|
||||
{lecturerName && (
|
||||
<p className="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="h-3 w-3 shrink-0" />
|
||||
{lecturerName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
show: canUpdate,
|
||||
onClick: () => handleEdit(material),
|
||||
},
|
||||
{
|
||||
label: 'Hapus',
|
||||
icon: (
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
),
|
||||
show: canDelete,
|
||||
onClick: () => handleDeleteClick(material),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{material.description && (
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{material.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{material.file_url && material.file_name ? (
|
||||
<Attachment size="sm" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>
|
||||
{material.file_name}
|
||||
</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
Lampiran materi
|
||||
</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentPreviewDialog
|
||||
fileUrl={material.file_url}
|
||||
fileName={material.file_name}
|
||||
variant="icon"
|
||||
/>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-dashed px-2.5 py-2 text-xs text-muted-foreground">
|
||||
<FileX className="h-4 w-4 shrink-0" />
|
||||
Tidak ada file dilampirkan
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-3">
|
||||
{material.meeting_number ? (
|
||||
<Badge variant="secondary">
|
||||
Pertemuan {material.meeting_number}
|
||||
</Badge>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
{format(new Date(material.created_at), 'd MMM yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -61,41 +61,30 @@ export function createMaterialColumns(
|
||||
return '-';
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return `${courseClass.course?.code ?? ''} · ${courseClass.course?.name ?? ''}`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (canUpdate || canDelete) {
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
className: 'w-[130px] text-center',
|
||||
headerClassName: 'w-[130px] 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={[
|
||||
{
|
||||
@ -114,10 +103,10 @@ export function createMaterialColumns(
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user