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;
|
use StyledHeadingRow;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly User $user,
|
||||||
private readonly string $search = '',
|
private readonly string $search = '',
|
||||||
private readonly ?string $gender = null,
|
private readonly ?string $gender = null,
|
||||||
private readonly ?int $departmentId = null,
|
private readonly ?int $departmentId = null,
|
||||||
@ -32,7 +33,7 @@ public function title(): string
|
|||||||
|
|
||||||
public function collection(): Enumerable
|
public function collection(): Enumerable
|
||||||
{
|
{
|
||||||
return app(StudentService::class)->forExport($this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear);
|
return app(StudentService::class)->forExport($this->user, $this->search, $this->gender, $this->departmentId, $this->status, $this->enrollmentYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\AssignmentRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\AssignmentRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
use App\Services\Admin\AcademicClasses\AssignmentService;
|
use App\Services\Admin\AcademicClasses\Assignment\AssignmentService;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@ -23,23 +23,15 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
$academicTermId = $request->has('academic_term_id')
|
|
||||||
? $request->validated('academic_term_id')
|
|
||||||
: $this->academicTermService->getActive()?->id;
|
|
||||||
|
|
||||||
return Inertia::render('admin/academic-classes/assignments/index', [
|
return Inertia::render('admin/academic-classes/assignments/index', [
|
||||||
'assignments' => Inertia::scroll(fn () => $this->service->paginated(
|
'assignments' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
$request->user(),
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
courseClassId: $request->validated('course_class_id'),
|
courseClassId: $request->validated('course_class_id'),
|
||||||
academicTermId: $academicTermId,
|
academicTermId: $this->academicTermService->getActive()?->id,
|
||||||
)),
|
)),
|
||||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'filters' => $request->only(['course_class_id']),
|
||||||
'filters' => [
|
|
||||||
'course_class_id' => $request->validated('course_class_id'),
|
|
||||||
'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +55,8 @@ public function update(AssignmentRequest $request, Assignment $assignment): Redi
|
|||||||
|
|
||||||
public function destroy(Assignment $assignment): RedirectResponse
|
public function destroy(Assignment $assignment): RedirectResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||||
|
|
||||||
$this->service->delete($assignment);
|
$this->service->delete($assignment);
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest;
|
use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
use App\Models\Submission;
|
use App\Models\Submission;
|
||||||
use App\Services\Admin\AcademicClasses\SubmissionService;
|
use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -20,7 +20,7 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(Assignment $assignment): Response
|
public function index(Assignment $assignment): Response
|
||||||
{
|
{
|
||||||
$this->abortUnlessLecturerOwnsAssignment($assignment);
|
abort_unless(request()->user()->canManageAssignment($assignment), 403);
|
||||||
|
|
||||||
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
|
$assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']);
|
||||||
|
|
||||||
@ -45,17 +45,4 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment)
|
|||||||
|
|
||||||
return to_route('admin.academic-classes.assignments.index');
|
return to_route('admin.academic-classes.assignments.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A dosen may only manage submissions for classes they lecture.
|
|
||||||
*/
|
|
||||||
private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void
|
|
||||||
{
|
|
||||||
$user = request()->user();
|
|
||||||
|
|
||||||
abort_if(
|
|
||||||
$user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id,
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -34,17 +34,6 @@ public function index(Request $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mine(Request $request): Response
|
|
||||||
{
|
|
||||||
$student = $request->user()->student;
|
|
||||||
|
|
||||||
abort_if(! $student, 403);
|
|
||||||
|
|
||||||
return Inertia::render('admin/academic-classes/attendances/mine', [
|
|
||||||
'summaries' => $this->service->forStudent($student),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(CourseClass $courseClass): Response
|
public function show(CourseClass $courseClass): Response
|
||||||
{
|
{
|
||||||
$this->abortUnlessLecturerOwnsClass($courseClass);
|
$this->abortUnlessLecturerOwnsClass($courseClass);
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Material;
|
use App\Models\Material;
|
||||||
use App\Services\Admin\AcademicClasses\MaterialService;
|
use App\Services\Admin\AcademicClasses\MaterialService;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||||
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -17,16 +18,18 @@ class MaterialController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MaterialService $service,
|
private readonly MaterialService $service,
|
||||||
private readonly CourseClassService $courseClassService,
|
private readonly CourseClassService $courseClassService,
|
||||||
|
private readonly AcademicTermService $academicTermService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/academic-classes/materials/index', [
|
return Inertia::render('admin/academic-classes/materials/index', [
|
||||||
'materials' => $this->service->paginated(
|
'materials' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
$request->user(),
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
courseClassId: $request->validated('course_class_id'),
|
courseClassId: $request->validated('course_class_id'),
|
||||||
),
|
academicTermId: $this->academicTermService->getActive()?->id,
|
||||||
|
)),
|
||||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||||
'filters' => $request->only(['course_class_id']),
|
'filters' => $request->only(['course_class_id']),
|
||||||
]);
|
]);
|
||||||
@ -52,6 +55,8 @@ public function update(MaterialRequest $request, Material $material): RedirectRe
|
|||||||
|
|
||||||
public function destroy(Material $material): RedirectResponse
|
public function destroy(Material $material): RedirectResponse
|
||||||
{
|
{
|
||||||
|
abort_unless(request()->user()->canManageMaterial($material), 403);
|
||||||
|
|
||||||
$this->service->delete($material);
|
$this->service->delete($material);
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Materi berhasil dihapus.'])->back();
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\AcademicClasses;
|
namespace App\Http\Controllers\Admin\AcademicClasses;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\AcademicClasses\ScheduleRequest;
|
use App\Http\Requests\Admin\AcademicClasses\ScheduleRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
@ -26,7 +27,7 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$isPersonalView = $user->hasRole('mahasiswa') || $user->hasRole('dosen');
|
$isPersonalView = $user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value);
|
||||||
|
|
||||||
$academicTermId = $request->has('academic_term_id')
|
$academicTermId = $request->has('academic_term_id')
|
||||||
? $request->validated('academic_term_id')
|
? $request->validated('academic_term_id')
|
||||||
|
|||||||
@ -22,11 +22,11 @@ public function index(PaginatedRequest $request): Response
|
|||||||
|
|
||||||
return Inertia::render('admin/developer/logs/index', [
|
return Inertia::render('admin/developer/logs/index', [
|
||||||
'entries' => $file
|
'entries' => $file
|
||||||
? $this->service->paginated(
|
? Inertia::scroll(fn () => $this->service->paginated(
|
||||||
$file,
|
$file,
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
level: $request->validated('level'),
|
level: $request->validated('level'),
|
||||||
)
|
))
|
||||||
: null,
|
: null,
|
||||||
'files' => $files,
|
'files' => $files,
|
||||||
'selectedFile' => $file,
|
'selectedFile' => $file,
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Enums\FeedbackType;
|
use App\Enums\FeedbackType;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Feedback\FeedbackRequest;
|
use App\Http\Requests\Admin\Feedback\FeedbackRequest;
|
||||||
|
use App\Http\Requests\Admin\Feedback\ReplyFeedbackRequest;
|
||||||
use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest;
|
use App\Http\Requests\Admin\Feedback\UpdateFeedbackStatusRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Feedback;
|
use App\Models\Feedback;
|
||||||
@ -24,12 +25,12 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/feedback/index', [
|
return Inertia::render('admin/feedback/index', [
|
||||||
'feedbacks' => $this->service->paginated(
|
'feedbacks' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
$request->user(),
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
type: $request->validated('type'),
|
type: $request->validated('type'),
|
||||||
status: $request->validated('status'),
|
status: $request->validated('status'),
|
||||||
),
|
)),
|
||||||
'types' => FeedbackType::options(),
|
'types' => FeedbackType::options(),
|
||||||
'statuses' => FeedbackStatus::options(),
|
'statuses' => FeedbackStatus::options(),
|
||||||
'filters' => $request->only(['type', 'status']),
|
'filters' => $request->only(['type', 'status']),
|
||||||
@ -55,6 +56,7 @@ public function store(FeedbackRequest $request): RedirectResponse
|
|||||||
public function edit(Request $request, Feedback $feedback): Response
|
public function edit(Request $request, Feedback $feedback): Response
|
||||||
{
|
{
|
||||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||||
|
abort_unless($feedback->status === FeedbackStatus::Submitted, 403);
|
||||||
|
|
||||||
return Inertia::render('admin/feedback/edit', [
|
return Inertia::render('admin/feedback/edit', [
|
||||||
'feedback' => $feedback,
|
'feedback' => $feedback,
|
||||||
@ -64,8 +66,6 @@ public function edit(Request $request, Feedback $feedback): Response
|
|||||||
|
|
||||||
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
|
public function update(FeedbackRequest $request, Feedback $feedback): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
|
||||||
|
|
||||||
$this->service->update($feedback, $request->validated());
|
$this->service->update($feedback, $request->validated());
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil diperbarui.']);
|
||||||
@ -73,6 +73,16 @@ public function update(FeedbackRequest $request, Feedback $feedback): RedirectRe
|
|||||||
return to_route('admin.feedback.index');
|
return to_route('admin.feedback.index');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Feedback $feedback): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($feedback->user_id === $request->user()->id, 403);
|
||||||
|
abort_unless($feedback->status === FeedbackStatus::Submitted, 403);
|
||||||
|
|
||||||
|
$this->service->delete($feedback);
|
||||||
|
|
||||||
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
|
||||||
|
}
|
||||||
|
|
||||||
public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $feedback): RedirectResponse
|
public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $feedback): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->service->updateStatus($feedback, $request->validated('status'));
|
$this->service->updateStatus($feedback, $request->validated('status'));
|
||||||
@ -80,12 +90,10 @@ public function updateStatus(UpdateFeedbackStatusRequest $request, Feedback $fee
|
|||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Status masukan berhasil diperbarui.'])->back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, Feedback $feedback): RedirectResponse
|
public function reply(ReplyFeedbackRequest $request, Feedback $feedback): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless($feedback->user_id === $request->user()->id, 403);
|
$this->service->reply($feedback, $request->validated('reply'));
|
||||||
|
|
||||||
$this->service->delete($feedback);
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Balasan berhasil dikirim.'])->back();
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Masukan berhasil dihapus.'])->back();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,12 +38,12 @@ public function index(PaginatedRequest $request): Response
|
|||||||
$summaryTermId = $requestedTermId ?? $this->academicTermService->getActive()?->id;
|
$summaryTermId = $requestedTermId ?? $this->academicTermService->getActive()?->id;
|
||||||
|
|
||||||
return Inertia::render('admin/finances/tuition-invoices/index', [
|
return Inertia::render('admin/finances/tuition-invoices/index', [
|
||||||
'invoices' => $this->service->paginated(
|
'invoices' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
academicTermId: $requestedTermId,
|
academicTermId: $requestedTermId,
|
||||||
status: $request->validated('status'),
|
status: $request->validated('status'),
|
||||||
paymentMethod: $request->validated('payment_method'),
|
paymentMethod: $request->validated('payment_method'),
|
||||||
),
|
)),
|
||||||
'summary' => $this->service->summary(
|
'summary' => $this->service->summary(
|
||||||
search: $request->validated('search') ?? '',
|
search: $request->validated('search') ?? '',
|
||||||
academicTermId: $summaryTermId,
|
academicTermId: $summaryTermId,
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
use App\Services\Admin\Manage\AnnouncementService;
|
use App\Services\Admin\Manage\AnnouncementService;
|
||||||
use App\Services\Admin\Master\DepartmentService;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -16,18 +15,15 @@ class AnnouncementController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AnnouncementService $service,
|
private readonly AnnouncementService $service,
|
||||||
private readonly DepartmentService $departmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/manage/announcements/index', [
|
return Inertia::render('admin/manage/announcements/index', [
|
||||||
'announcements' => $this->service->paginated(
|
'announcements' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
departmentId: $request->validated('department_id'),
|
)),
|
||||||
),
|
|
||||||
'departments' => $this->departmentService->getAllForSelect(),
|
|
||||||
'filters' => $request->only(['department_id']),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Services\Admin\Manage\ClassEnrollmentService;
|
use App\Services\Admin\Manage\CourseClass\ClassEnrollmentService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\CourseClassRequest;
|
use App\Http\Requests\Admin\Manage\CourseClass\CourseClassRequest;
|
||||||
use App\Http\Requests\Admin\Manage\DuplicateCourseClassesRequest;
|
use App\Http\Requests\Admin\Manage\CourseClass\DuplicateCourseClassesRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Services\Admin\Manage\CourseClassService;
|
use App\Services\Admin\Manage\CourseClass\CourseClassService;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use App\Services\Admin\Master\CourseService;
|
use App\Services\Admin\Master\CourseService;
|
||||||
use App\Services\Admin\Users\LecturerService;
|
use App\Services\Admin\Users\LecturerService;
|
||||||
@ -27,15 +27,15 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/manage/course-classes/index', [
|
return Inertia::render('admin/manage/course-classes/index', [
|
||||||
'courseClasses' => $this->service->paginated(
|
'courseClasses' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
academicTermId: $request->validated('academic_term_id'),
|
academicTermId: $request->validated('academic_term_id'),
|
||||||
method: $request->validated('method'),
|
method: $request->validated('method'),
|
||||||
),
|
)),
|
||||||
'courses' => $this->courseService->getAllForSelect(),
|
'courses' => $this->courseService->getAllForSelect(),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
||||||
'courseClassAssignments' => $this->service->getAllForSelect(),
|
'courseClassAssignments' => $this->service->getAllForSelect($request->user()),
|
||||||
'filters' => $request->only(['academic_term_id', 'method']),
|
'filters' => $request->only(['academic_term_id', 'method']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\RejectCourseRegistrationRequest;
|
use App\Http\Requests\Admin\Manage\CourseRegistration\RejectCourseRegistrationRequest;
|
||||||
use App\Http\Requests\Admin\Manage\SaveCourseRegistrationRequest;
|
use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsAdvisorCourseRegistrationRequest;
|
||||||
use App\Http\Requests\Admin\Manage\SignCourseRegistrationRequest;
|
use App\Http\Requests\Admin\Manage\CourseRegistration\SignAsDepartmentLeaderCourseRegistrationRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\CourseRegistration\UpdateCourseRegistrationRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\Course;
|
use App\Models\Course;
|
||||||
use App\Models\CourseRegistrationSubmission;
|
use App\Models\CourseRegistrationSubmission;
|
||||||
@ -25,30 +27,27 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(PaginatedRequest $request): Response|RedirectResponse
|
public function index(PaginatedRequest $request): Response|RedirectResponse
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if ($user->hasRole('mahasiswa')) {
|
if ($user->hasRole(UserRole::Mahasiswa->value)) {
|
||||||
return to_route('admin.manage.course-registrations.show', $user->student->id);
|
return to_route('admin.manage.course-registrations.show', $user->student->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$isKaprodi = $user->hasRole('kaprodi');
|
|
||||||
|
|
||||||
return Inertia::render('admin/manage/course-registrations/index', [
|
return Inertia::render('admin/manage/course-registrations/index', [
|
||||||
'departments' => $this->service->departmentSummary(),
|
'departments' => $this->service->departmentSummary(),
|
||||||
'registrations' => $this->service->paginated(
|
'registrations' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$user,
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
departmentId: $request->validated('department_id'),
|
departmentId: $request->validated('department_id'),
|
||||||
advisorLecturerId: ! $isKaprodi && $user->hasRole('dosen') ? $user->lecturer?->id : null,
|
)),
|
||||||
ledDepartmentIds: $isKaprodi ? $user->ledDepartmentIds() : null,
|
|
||||||
),
|
|
||||||
'filters' => $request->only(['department_id']),
|
'filters' => $request->only(['department_id']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Student $student): Response
|
public function show(Student $student): Response
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = request()->user();
|
||||||
$isStudent = $user->hasRole('mahasiswa');
|
$isStudent = $user->hasRole(UserRole::Mahasiswa->value);
|
||||||
|
|
||||||
abort_if($isStudent && $user->student->id !== $student->id, 403);
|
abort_if($isStudent && $user->student->id !== $student->id, 403);
|
||||||
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
|
abort_if(! $isStudent && ! $user->canAccessCourseRegistrationOf($student), 403);
|
||||||
@ -61,7 +60,7 @@ public function show(Student $student): Response
|
|||||||
&& $user->can('approve-course-registrations')
|
&& $user->can('approve-course-registrations')
|
||||||
&& $user->canReviewCourseRegistrationOf($student);
|
&& $user->canReviewCourseRegistrationOf($student);
|
||||||
|
|
||||||
$canSignAsKaprodi = $user->isKaprodiOf($student);
|
$canSignAsDepartmentLeader = $user->isDepartmentLeaderOf($student);
|
||||||
$canSignAsAdvisor = $user->isAdvisorOf($student);
|
$canSignAsAdvisor = $user->isAdvisorOf($student);
|
||||||
|
|
||||||
$activeTerm = $this->academicTermService->getActive();
|
$activeTerm = $this->academicTermService->getActive();
|
||||||
@ -97,12 +96,12 @@ public function show(Student $student): Response
|
|||||||
'hasActiveTerm' => $activeTerm !== null,
|
'hasActiveTerm' => $activeTerm !== null,
|
||||||
'backHref' => $backHref,
|
'backHref' => $backHref,
|
||||||
'canReview' => $canReview,
|
'canReview' => $canReview,
|
||||||
'canSignAsKaprodi' => $canSignAsKaprodi,
|
'canSignAsDepartmentLeader' => $canSignAsDepartmentLeader,
|
||||||
'canSignAsAdvisor' => $canSignAsAdvisor,
|
'canSignAsAdvisor' => $canSignAsAdvisor,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function save(Student $student, string $semester, SaveCourseRegistrationRequest $request): RedirectResponse
|
public function update(Student $student, string $semester, UpdateCourseRegistrationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
|
$semesterNumber = CourseRegistrationSubmission::parseSemesterKey($semester);
|
||||||
|
|
||||||
@ -138,23 +137,19 @@ public function save(Student $student, string $semester, SaveCourseRegistrationR
|
|||||||
return to_route('admin.manage.course-registrations.show', $student->id);
|
return to_route('admin.manage.course-registrations.show', $student->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signAsKaprodi(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
|
public function signAsDepartmentLeader(Student $student, string $semester, SignAsDepartmentLeaderCourseRegistrationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->isKaprodiOf($student), 403);
|
|
||||||
|
|
||||||
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
||||||
|
|
||||||
$this->service->signAsKaprodi($submission, $request->file('signature'));
|
$this->service->signAsDepartmentLeader($submission, $request->file('signature'));
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Tanda tangan berhasil disimpan.']);
|
||||||
|
|
||||||
return to_route('admin.manage.course-registrations.show', $student->id);
|
return to_route('admin.manage.course-registrations.show', $student->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signAsAdvisor(Student $student, string $semester, SignCourseRegistrationRequest $request): RedirectResponse
|
public function signAsAdvisor(Student $student, string $semester, SignAsAdvisorCourseRegistrationRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->isAdvisorOf($student), 403);
|
|
||||||
|
|
||||||
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
$submission = $this->service->findOrCreateForSemester($student, $semester);
|
||||||
|
|
||||||
$this->service->signAsAdvisor($submission, $request->file('signature'));
|
$this->service->signAsAdvisor($submission, $request->file('signature'));
|
||||||
@ -166,7 +161,11 @@ public function signAsAdvisor(Student $student, string $semester, SignCourseRegi
|
|||||||
|
|
||||||
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
|
public function approve(CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
|
abort_unless(
|
||||||
|
request()->user()->can('approve-course-registrations')
|
||||||
|
&& request()->user()->canReviewCourseRegistrationOf($submission->student),
|
||||||
|
403
|
||||||
|
);
|
||||||
|
|
||||||
$this->service->approve($submission);
|
$this->service->approve($submission);
|
||||||
|
|
||||||
@ -175,8 +174,6 @@ public function approve(CourseRegistrationSubmission $submission): RedirectRespo
|
|||||||
|
|
||||||
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
|
public function reject(RejectCourseRegistrationRequest $request, CourseRegistrationSubmission $submission): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->canReviewCourseRegistrationOf($submission->student), 403);
|
|
||||||
|
|
||||||
$this->service->reject($submission, $request->validated('reason'));
|
$this->service->reject($submission, $request->validated('reason'));
|
||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'KRS berhasil ditolak.'])->back();
|
||||||
|
|||||||
@ -21,11 +21,11 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/master/academic-terms/index', [
|
return Inertia::render('admin/master/academic-terms/index', [
|
||||||
'academicTerms' => $this->service->paginated(
|
'academicTerms' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
semester: $request->validated('semester'),
|
semester: $request->validated('semester'),
|
||||||
isActive: $request->filled('is_active') ? filter_var($request->validated('is_active'), FILTER_VALIDATE_BOOLEAN) : null,
|
isActive: $request->filled('is_active') ? $request->boolean('is_active') : null,
|
||||||
),
|
)),
|
||||||
'filters' => $request->only(['semester', 'is_active']),
|
'filters' => $request->only(['semester', 'is_active']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,11 +22,12 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/master/courses/index', [
|
return Inertia::render('admin/master/courses/index', [
|
||||||
'courses' => $this->service->paginated(
|
'courses' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
departmentId: $request->validated('department_id'),
|
departmentId: $request->validated('department_id'),
|
||||||
semesterNumber: $request->validated('semester_number'),
|
semesterNumber: $request->validated('semester_number'),
|
||||||
),
|
)),
|
||||||
'departments' => $this->departmentService->getAllForSelect(),
|
'departments' => $this->departmentService->getAllForSelect(),
|
||||||
'semesterNumbers' => $this->service->getSemesterNumbers(),
|
'semesterNumbers' => $this->service->getSemesterNumbers(),
|
||||||
'filters' => $request->only(['department_id', 'semester_number']),
|
'filters' => $request->only(['department_id', 'semester_number']),
|
||||||
|
|||||||
@ -22,7 +22,7 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/master/departments/index', [
|
return Inertia::render('admin/master/departments/index', [
|
||||||
'departments' => $this->service->paginated(...$request->validatedWithDefaults()),
|
'departments' => Inertia::scroll(fn () => $this->service->paginated(...$request->validatedWithDefaults())),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,10 +24,11 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/services/academic-advising-logs/index', [
|
return Inertia::render('admin/services/academic-advising-logs/index', [
|
||||||
'logs' => $this->service->paginated(
|
'logs' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
lecturerId: $request->validated('lecturer_id'),
|
lecturerId: $request->validated('lecturer_id'),
|
||||||
),
|
)),
|
||||||
'students' => $this->studentService->getAllForSelect(),
|
'students' => $this->studentService->getAllForSelect(),
|
||||||
'lecturers' => $this->lecturerService->getAllForSelect(),
|
'lecturers' => $this->lecturerService->getAllForSelect(),
|
||||||
'filters' => $request->only(['lecturer_id']),
|
'filters' => $request->only(['lecturer_id']),
|
||||||
|
|||||||
@ -22,10 +22,11 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/services/letter-requests/index', [
|
return Inertia::render('admin/services/letter-requests/index', [
|
||||||
'letterRequests' => $this->service->paginated(
|
'letterRequests' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
status: $request->validated('status'),
|
status: $request->validated('status'),
|
||||||
),
|
)),
|
||||||
'filters' => $request->only(['status']),
|
'filters' => $request->only(['status']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -53,7 +54,7 @@ public function update(LetterRequestRequest $request, LetterRequest $letterReque
|
|||||||
|
|
||||||
public function destroy(LetterRequest $letterRequest): RedirectResponse
|
public function destroy(LetterRequest $letterRequest): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_if($letterRequest->status !== LetterStatus::Submitted->value, 403);
|
abort_if($letterRequest->status !== LetterStatus::Submitted, 403);
|
||||||
|
|
||||||
$this->service->delete($letterRequest);
|
$this->service->delete($letterRequest);
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
use Spatie\Permission\Models\Role;
|
|
||||||
|
|
||||||
class AdministratorController extends Controller
|
class AdministratorController extends Controller
|
||||||
{
|
{
|
||||||
@ -22,10 +21,10 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/users/administrators/index', [
|
return Inertia::render('admin/users/administrators/index', [
|
||||||
'administrators' => $this->service->paginated(
|
'administrators' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
gender: $request->validated('gender'),
|
gender: $request->validated('gender'),
|
||||||
),
|
)),
|
||||||
'filters' => $request->only(['gender']),
|
'filters' => $request->only(['gender']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -33,7 +32,7 @@ public function index(PaginatedRequest $request): Response
|
|||||||
public function create(): Response
|
public function create(): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/users/administrators/create', [
|
return Inertia::render('admin/users/administrators/create', [
|
||||||
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
|
'roles' => $this->service->availableRoles(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +51,7 @@ public function edit(User $user): Response
|
|||||||
|
|
||||||
return Inertia::render('admin/users/administrators/edit', [
|
return Inertia::render('admin/users/administrators/edit', [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'roles' => Role::whereIn('name', ['staff-admin', 'staff-keuangan'])->get(['id', 'name']),
|
'roles' => $this->service->availableRoles(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,11 +26,11 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/users/lecturers/index', [
|
return Inertia::render('admin/users/lecturers/index', [
|
||||||
'lecturers' => $this->service->paginated(
|
'lecturers' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
gender: $request->validated('gender'),
|
gender: $request->validated('gender'),
|
||||||
departmentId: $request->validated('department_id'),
|
departmentId: $request->validated('department_id'),
|
||||||
),
|
)),
|
||||||
'departments' => $this->departmentService->getAllForSelect(),
|
'departments' => $this->departmentService->getAllForSelect(),
|
||||||
'filters' => $request->only(['gender', 'department_id']),
|
'filters' => $request->only(['gender', 'department_id']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -30,13 +30,14 @@ public function __construct(
|
|||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
return Inertia::render('admin/users/students/index', [
|
return Inertia::render('admin/users/students/index', [
|
||||||
'students' => $this->service->paginated(
|
'students' => Inertia::scroll(fn () => $this->service->paginated(
|
||||||
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
gender: $request->validated('gender'),
|
gender: $request->validated('gender'),
|
||||||
departmentId: $request->validated('department_id'),
|
departmentId: $request->validated('department_id'),
|
||||||
status: $request->validated('status'),
|
status: $request->validated('status'),
|
||||||
enrollmentYear: $request->validated('enrollment_year'),
|
enrollmentYear: $request->validated('enrollment_year'),
|
||||||
),
|
)),
|
||||||
'departments' => $this->departmentService->getAllForSelect(),
|
'departments' => $this->departmentService->getAllForSelect(),
|
||||||
'statuses' => StudentStatus::options(),
|
'statuses' => StudentStatus::options(),
|
||||||
'enrollmentYears' => $this->service->getEnrollmentYears(),
|
'enrollmentYears' => $this->service->getEnrollmentYears(),
|
||||||
@ -112,6 +113,7 @@ public function updateUserStatus(UpdateStatusRequest $request, User $user): Redi
|
|||||||
public function export(PaginatedRequest $request): BinaryFileResponse
|
public function export(PaginatedRequest $request): BinaryFileResponse
|
||||||
{
|
{
|
||||||
return Excel::download(new StudentsExport(
|
return Excel::download(new StudentsExport(
|
||||||
|
user: $request->user(),
|
||||||
search: $request->validated('search') ?? '',
|
search: $request->validated('search') ?? '',
|
||||||
gender: $request->validated('gender'),
|
gender: $request->validated('gender'),
|
||||||
departmentId: $request->validated('department_id'),
|
departmentId: $request->validated('department_id'),
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -10,7 +11,12 @@ class AssignmentRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()->can($this->isMethod('post') ? 'create-assignments' : 'update-assignments');
|
if ($this->isMethod('post')) {
|
||||||
|
return $this->user()->can('create-assignments');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()->can('update-assignments')
|
||||||
|
&& $this->user()->canManageAssignment($this->route('assignment'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
@ -19,7 +25,7 @@ public function rules(): array
|
|||||||
|
|
||||||
$courseClassRule = Rule::exists('course_classes', 'id');
|
$courseClassRule = Rule::exists('course_classes', 'id');
|
||||||
|
|
||||||
if ($user->hasRole('dosen')) {
|
if ($user->hasRole(UserRole::Dosen->value)) {
|
||||||
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\AcademicClasses;
|
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
@ -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;
|
namespace App\Http\Requests\Admin\AcademicClasses;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -9,7 +10,12 @@ class MaterialRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()->can($this->isMethod('post') ? 'create-materials' : 'update-materials');
|
if ($this->isMethod('post')) {
|
||||||
|
return $this->user()->can('create-materials');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()->can('update-materials')
|
||||||
|
&& $this->user()->canManageMaterial($this->route('material'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
@ -18,7 +24,7 @@ public function rules(): array
|
|||||||
|
|
||||||
$courseClassRule = Rule::exists('course_classes', 'id');
|
$courseClassRule = Rule::exists('course_classes', 'id');
|
||||||
|
|
||||||
if ($user->hasRole('dosen')) {
|
if ($user->hasRole(UserRole::Dosen->value)) {
|
||||||
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
$courseClassRule->where('lecturer_id', $user->lecturer?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public function rules(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')],
|
'course_class_id' => ['required', 'integer', Rule::exists('course_classes', 'id')],
|
||||||
'day_of_week' => ['required', 'string', Rule::in(DayOfWeek::values())],
|
'day_of_week' => ['required', 'string', Rule::enum(DayOfWeek::class)],
|
||||||
'start_time' => ['required', 'date_format:H:i'],
|
'start_time' => ['required', 'date_format:H:i'],
|
||||||
'end_time' => ['required', 'date_format:H:i', 'after:start_time'],
|
'end_time' => ['required', 'date_format:H:i', 'after:start_time'],
|
||||||
'room' => ['nullable', 'string', 'max:20'],
|
'room' => ['nullable', 'string', 'max:20'],
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin\Feedback;
|
namespace App\Http\Requests\Admin\Feedback;
|
||||||
|
|
||||||
|
use App\Enums\FeedbackStatus;
|
||||||
use App\Enums\FeedbackType;
|
use App\Enums\FeedbackType;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@ -10,13 +11,21 @@ class FeedbackRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()->can($this->isMethod('post') ? 'create-feedback' : 'update-feedback');
|
if ($this->isMethod('post')) {
|
||||||
|
return $this->user()->can('create-feedback');
|
||||||
|
}
|
||||||
|
|
||||||
|
$feedback = $this->route('feedback');
|
||||||
|
|
||||||
|
return $this->user()->can('update-feedback')
|
||||||
|
&& $feedback->user_id === $this->user()->id
|
||||||
|
&& $feedback->status === FeedbackStatus::Submitted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => ['required', 'string', Rule::in(FeedbackType::values())],
|
'type' => ['required', 'string', Rule::enum(FeedbackType::class)],
|
||||||
'subject' => ['required', 'string', 'max:150'],
|
'subject' => ['required', 'string', 'max:150'],
|
||||||
'message' => ['required', 'string'],
|
'message' => ['required', 'string'],
|
||||||
];
|
];
|
||||||
|
|||||||
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;
|
namespace App\Http\Requests\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -17,8 +18,10 @@ public function rules(): array
|
|||||||
return [
|
return [
|
||||||
'title' => ['required', 'string', 'max:200'],
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'content' => ['required', 'string'],
|
'content' => ['required', 'string'],
|
||||||
'department_id' => ['nullable', 'integer', Rule::exists('departments', 'id')],
|
'target_roles' => ['required', 'array', 'min:1'],
|
||||||
'enrollment_year' => ['nullable', 'integer', 'digits:4'],
|
'target_roles.*' => [
|
||||||
|
Rule::enum(UserRole::class)->only([UserRole::Mahasiswa, UserRole::Dosen]),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
use App\Enums\ClassMethod;
|
use App\Enums\ClassMethod;
|
||||||
use App\Enums\Semester;
|
use App\Enums\Semester;
|
||||||
@ -69,7 +69,7 @@ function ($attribute, $value, $fail) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||||
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
|
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ function ($attribute, $value, $fail) {
|
|||||||
Rule::exists('lecturer_department', 'lecturer_id')->where('department_id', $departmentId),
|
Rule::exists('lecturer_department', 'lecturer_id')->where('department_id', $departmentId),
|
||||||
],
|
],
|
||||||
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
'academic_term_id' => ['required', 'integer', Rule::exists('academic_terms', 'id')],
|
||||||
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
|
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
use App\Models\AcademicTerm;
|
use App\Models\AcademicTerm;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
@ -8,7 +8,8 @@ class RejectCourseRegistrationRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return $this->user()->can('reject-course-registrations');
|
return $this->user()->can('reject-course-registrations')
|
||||||
|
&& $this->user()->canReviewCourseRegistrationOf($this->route('submission')->student);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
@ -17,11 +18,4 @@ public function rules(): array
|
|||||||
'reason' => ['required', 'string', 'max:1000'],
|
'reason' => ['required', 'string', 'max:1000'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function messages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'reason.required' => 'Alasan penolakan wajib diisi.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,14 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class SignCourseRegistrationRequest extends FormRequest
|
class SignAsAdvisorCourseRegistrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return $this->user()->isAdvisorOf($this->route('student'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
@ -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
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Requests\Admin\Manage;
|
namespace App\Http\Requests\Admin\Manage\CourseRegistration;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class SaveCourseRegistrationRequest extends FormRequest
|
class UpdateCourseRegistrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
$student = $this->route('student');
|
$student = $this->route('student');
|
||||||
|
|
||||||
return $this->user()->hasRole('mahasiswa')
|
return $this->user()->hasRole(UserRole::Mahasiswa->value)
|
||||||
&& $this->user()->student?->id === $student?->id;
|
&& $this->user()->student?->id === $student?->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ function ($attribute, $value, $fail) {
|
|||||||
'semester' => [
|
'semester' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
Rule::in(Semester::values()),
|
Rule::enum(Semester::class),
|
||||||
Rule::unique('academic_terms')->where(function ($query) {
|
Rule::unique('academic_terms')->where(function ($query) {
|
||||||
return $query->where('academic_year', $this->input('academic_year'));
|
return $query->where('academic_year', $this->input('academic_year'));
|
||||||
})->ignore($this->route('academic_term')),
|
})->ignore($this->route('academic_term')),
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Admin\Master;
|
namespace App\Http\Requests\Admin\Master;
|
||||||
|
|
||||||
|
use App\Enums\DegreeLevel;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ public function rules(): array
|
|||||||
Rule::unique('departments')->ignore($this->route('department')),
|
Rule::unique('departments')->ignore($this->route('department')),
|
||||||
],
|
],
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
'degree_level' => ['nullable', 'string', Rule::in(['D3', 'D4', 'S1', 'S2', 'S3'])],
|
'degree_level' => ['nullable', 'string', Rule::enum(DegreeLevel::class)],
|
||||||
'lecturer_id' => [
|
'lecturer_id' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
Rule::requiredIf($this->route('department') !== null),
|
Rule::requiredIf($this->route('department') !== null),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public function authorize(): bool
|
|||||||
|
|
||||||
return $this->user()->can('update-letter-requests')
|
return $this->user()->can('update-letter-requests')
|
||||||
&& $letterRequest->user_id === $this->user()->id
|
&& $letterRequest->user_id === $this->user()->id
|
||||||
&& $letterRequest->status === LetterStatus::Submitted->value;
|
&& $letterRequest->status === LetterStatus::Submitted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Requests\Admin\Users;
|
namespace App\Http\Requests\Admin\Users;
|
||||||
|
|
||||||
use App\Enums\Gender;
|
use App\Enums\Gender;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -37,13 +38,17 @@ public function rules(): array
|
|||||||
? Rule::unique('users')
|
? Rule::unique('users')
|
||||||
: Rule::unique('users')->ignore($userId),
|
: Rule::unique('users')->ignore($userId),
|
||||||
],
|
],
|
||||||
'role' => ['required', 'string', Rule::in(['staff-admin', 'staff-keuangan'])],
|
'role' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
Rule::enum(UserRole::class)->only([UserRole::StaffAdmin, UserRole::StaffKeuangan]),
|
||||||
|
],
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
'full_name' => ['required', 'string', 'max:150'],
|
'full_name' => ['required', 'string', 'max:150'],
|
||||||
'phone_number' => ['required', 'string', 'max:20'],
|
'phone_number' => ['required', 'string', 'max:20'],
|
||||||
'address' => ['required', 'string'],
|
'address' => ['required', 'string'],
|
||||||
'gender' => ['required', 'string', Rule::in(Gender::values())],
|
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||||
'birth_date' => ['required', 'date'],
|
'birth_date' => ['required', 'date'],
|
||||||
'birth_place' => ['required', 'string', 'max:100'],
|
'birth_place' => ['required', 'string', 'max:100'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -42,7 +42,7 @@ public function rules(): array
|
|||||||
'full_name' => ['required', 'string', 'max:150'],
|
'full_name' => ['required', 'string', 'max:150'],
|
||||||
'phone_number' => ['required', 'string', 'max:20'],
|
'phone_number' => ['required', 'string', 'max:20'],
|
||||||
'address' => ['required', 'string'],
|
'address' => ['required', 'string'],
|
||||||
'gender' => ['required', 'string', Rule::in(Gender::values())],
|
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||||
'birth_date' => ['required', 'date'],
|
'birth_date' => ['required', 'date'],
|
||||||
'birth_place' => ['required', 'string', 'max:100'],
|
'birth_place' => ['required', 'string', 'max:100'],
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ public function rules(): array
|
|||||||
'full_name' => ['required', 'string', 'max:150'],
|
'full_name' => ['required', 'string', 'max:150'],
|
||||||
'phone_number' => ['required', 'string', 'max:20'],
|
'phone_number' => ['required', 'string', 'max:20'],
|
||||||
'address' => ['required', 'string'],
|
'address' => ['required', 'string'],
|
||||||
'gender' => ['required', 'string', Rule::in(Gender::values())],
|
'gender' => ['required', 'string', Rule::enum(Gender::class)],
|
||||||
'birth_date' => ['required', 'date'],
|
'birth_date' => ['required', 'date'],
|
||||||
'birth_place' => ['required', 'string', 'max:100'],
|
'birth_place' => ['required', 'string', 'max:100'],
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ public function rules(): array
|
|||||||
'status' => [
|
'status' => [
|
||||||
'nullable',
|
'nullable',
|
||||||
'string',
|
'string',
|
||||||
Rule::in(StudentStatus::values()),
|
Rule::enum(StudentStatus::class),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ public function authorize(): bool
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => ['required', 'string', Rule::in(StudentStatus::values())],
|
'status' => ['required', 'string', Rule::enum(StudentStatus::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,19 +22,19 @@ public function rules(): array
|
|||||||
'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
|
'per_page' => ['nullable', 'integer', 'in:25,50,100,999999'],
|
||||||
'search' => ['nullable', 'string', 'max:255'],
|
'search' => ['nullable', 'string', 'max:255'],
|
||||||
'highlight' => ['nullable', 'integer'],
|
'highlight' => ['nullable', 'integer'],
|
||||||
'gender' => ['nullable', 'string', Rule::in(Gender::values())],
|
'gender' => ['nullable', 'string', Rule::enum(Gender::class)],
|
||||||
'department_id' => ['nullable', 'integer'],
|
'department_id' => ['nullable', 'integer'],
|
||||||
'status' => ['nullable', 'string'],
|
'status' => ['nullable', 'string'],
|
||||||
'enrollment_year' => ['nullable', 'integer'],
|
'enrollment_year' => ['nullable', 'integer'],
|
||||||
'semester_number' => ['nullable', 'integer'],
|
'semester_number' => ['nullable', 'integer'],
|
||||||
'semester' => ['nullable', 'string', Rule::in(Semester::values())],
|
'semester' => ['nullable', 'string', Rule::enum(Semester::class)],
|
||||||
'is_active' => ['nullable', Rule::in(['true', 'false'])],
|
'is_active' => ['nullable', Rule::in(['true', 'false'])],
|
||||||
'academic_term_id' => ['nullable', 'integer'],
|
'academic_term_id' => ['nullable', 'integer'],
|
||||||
'method' => ['nullable', 'string', Rule::in(ClassMethod::values())],
|
'method' => ['nullable', 'string', Rule::enum(ClassMethod::class)],
|
||||||
'course_class_id' => ['nullable', 'integer'],
|
'course_class_id' => ['nullable', 'integer'],
|
||||||
'lecturer_id' => ['nullable', 'integer'],
|
'lecturer_id' => ['nullable', 'integer'],
|
||||||
'type' => ['nullable', 'string'],
|
'type' => ['nullable', 'string'],
|
||||||
'payment_method' => ['nullable', 'string', Rule::in(PaymentMethod::values())],
|
'payment_method' => ['nullable', 'string', Rule::enum(PaymentMethod::class)],
|
||||||
'file' => ['nullable', 'string'],
|
'file' => ['nullable', 'string'],
|
||||||
'level' => ['nullable', 'string'],
|
'level' => ['nullable', 'string'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,13 +13,13 @@ class AcademicAdvisingLog extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
public function students(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Student::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lecturer(): BelongsTo
|
public function lecturer(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Lecturer::class);
|
return $this->belongsTo(Lecturer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function students(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Student::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,21 @@ class AcademicTerm extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public function courseClasses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseClass::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courseRegistrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courseRegistrationSubmissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistrationSubmission::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function tuitionInvoices(): HasMany
|
public function tuitionInvoices(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TuitionInvoice::class);
|
return $this->hasMany(TuitionInvoice::class);
|
||||||
|
|||||||
@ -13,9 +13,11 @@ class Announcement extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
public function department(): BelongsTo
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Department::class);
|
return [
|
||||||
|
'target_roles' => 'array',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function creator(): BelongsTo
|
public function creator(): BelongsTo
|
||||||
|
|||||||
@ -22,6 +22,11 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function academicTerm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(AcademicTerm::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function course(): BelongsTo
|
public function course(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Course::class);
|
return $this->belongsTo(Course::class);
|
||||||
@ -32,9 +37,14 @@ public function lecturer(): BelongsTo
|
|||||||
return $this->belongsTo(Lecturer::class);
|
return $this->belongsTo(Lecturer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function academicTerm(): BelongsTo
|
public function assignments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsTo(AcademicTerm::class);
|
return $this->hasMany(Assignment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attendances(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attendance::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enrollments(): HasMany
|
public function enrollments(): HasMany
|
||||||
@ -47,23 +57,13 @@ public function materials(): HasMany
|
|||||||
return $this->hasMany(Material::class);
|
return $this->hasMany(Material::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function assignments(): HasMany
|
public function registrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Assignment::class);
|
return $this->hasMany(CourseRegistration::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function schedules(): HasMany
|
public function schedules(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Schedule::class);
|
return $this->hasMany(Schedule::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attendances(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Attendance::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registrations(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(CourseRegistration::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,11 +12,6 @@ class CourseRegistration extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
public function student(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Student::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function academicTerm(): BelongsTo
|
public function academicTerm(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(AcademicTerm::class);
|
return $this->belongsTo(AcademicTerm::class);
|
||||||
@ -27,6 +22,11 @@ public function courseClass(): BelongsTo
|
|||||||
return $this->belongsTo(CourseClass::class);
|
return $this->belongsTo(CourseClass::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function student(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Student::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function submission(): BelongsTo
|
public function submission(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['signature_url', 'kaprodi_signature_url', 'advisor_signature_url'])]
|
#[Appends(['signature_url', 'department_leader_signature_url', 'advisor_signature_url'])]
|
||||||
class CourseRegistrationSubmission extends Model implements HasMedia
|
class CourseRegistrationSubmission extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use InteractsWithMedia;
|
use InteractsWithMedia;
|
||||||
@ -31,15 +31,10 @@ protected function casts(): array
|
|||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('signature')->singleFile();
|
$this->addMediaCollection('signature')->singleFile();
|
||||||
$this->addMediaCollection('kaprodi_signature')->singleFile();
|
$this->addMediaCollection('department_leader_signature')->singleFile();
|
||||||
$this->addMediaCollection('advisor_signature')->singleFile();
|
$this->addMediaCollection('advisor_signature')->singleFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function student(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Student::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function academicTerm(): BelongsTo
|
public function academicTerm(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(AcademicTerm::class);
|
return $this->belongsTo(AcademicTerm::class);
|
||||||
@ -50,6 +45,11 @@ public function reviewer(): BelongsTo
|
|||||||
return $this->belongsTo(User::class, 'reviewed_by');
|
return $this->belongsTo(User::class, 'reviewed_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function student(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Student::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function courseRegistrations(): HasMany
|
public function courseRegistrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CourseRegistration::class, 'submission_id');
|
return $this->hasMany(CourseRegistration::class, 'submission_id');
|
||||||
@ -67,10 +67,10 @@ protected function signatureUrl(): Attribute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function kaprodiSignatureUrl(): Attribute
|
protected function departmentLeaderSignatureUrl(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => $this->getFirstMediaUrl('kaprodi_signature') ?: null,
|
get: fn () => $this->getFirstMediaUrl('department_leader_signature') ?: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,13 +17,13 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function submission(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function actor(): BelongsTo
|
public function actor(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'actor_id');
|
return $this->belongsTo(User::class, 'actor_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function submission(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CourseRegistrationSubmission::class, 'submission_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\DegreeLevel;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -15,14 +16,21 @@ class Department extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
public function lecturers(): BelongsToMany
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Lecturer::class, 'lecturer_department');
|
return [
|
||||||
|
'degree_level' => DegreeLevel::class,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function students(): HasMany
|
public function currentLeader(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasMany(Student::class);
|
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function courses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Course::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function leaderships(): HasMany
|
public function leaderships(): HasMany
|
||||||
@ -30,8 +38,13 @@ public function leaderships(): HasMany
|
|||||||
return $this->hasMany(DepartmentLeadership::class);
|
return $this->hasMany(DepartmentLeadership::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentLeader(): HasOne
|
public function students(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasOne(DepartmentLeadership::class)->whereNull('ended_at');
|
return $this->hasMany(Student::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lecturers(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Lecturer::class, 'lecturer_department');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,16 +21,17 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'type' => FeedbackType::class,
|
'type' => FeedbackType::class,
|
||||||
'status' => FeedbackStatus::class,
|
'status' => FeedbackStatus::class,
|
||||||
|
'replied_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handler(): BelongsTo
|
public function handler(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'handled_by');
|
return $this->belongsTo(User::class, 'handled_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,9 @@ public function user(): BelongsTo
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function departments(): BelongsToMany
|
public function academicAdvisingLogs(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Department::class, 'lecturer_department');
|
return $this->hasMany(AcademicAdvisingLog::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function advisees(): HasMany
|
public function advisees(): HasMany
|
||||||
@ -30,13 +30,18 @@ public function advisees(): HasMany
|
|||||||
return $this->hasMany(Student::class, 'academic_advisor_id');
|
return $this->hasMany(Student::class, 'academic_advisor_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function courseClasses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseClass::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function leaderships(): HasMany
|
public function leaderships(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DepartmentLeadership::class);
|
return $this->hasMany(DepartmentLeadership::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function academicAdvisingLogs(): HasMany
|
public function departments(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AcademicAdvisingLog::class);
|
return $this->belongsToMany(Department::class, 'lecturer_department');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,16 +32,16 @@ public function registerMediaCollections(): void
|
|||||||
$this->addMediaCollection('letter_result')->singleFile();
|
$this->addMediaCollection('letter_result')->singleFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function processor(): BelongsTo
|
public function processor(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'processed_by');
|
return $this->belongsTo(User::class, 'processed_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resultUrl(): Attribute
|
protected function resultUrl(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
@ -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
|
public function creator(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'created_by');
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
@ -22,9 +23,9 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function academicAdvisor(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function department(): BelongsTo
|
public function department(): BelongsTo
|
||||||
@ -32,19 +33,14 @@ public function department(): BelongsTo
|
|||||||
return $this->belongsTo(Department::class);
|
return $this->belongsTo(Department::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function academicAdvisor(): BelongsTo
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Lecturer::class, 'academic_advisor_id');
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enrollments(): HasMany
|
public function academicAdvisingLogs(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ClassEnrollment::class);
|
return $this->hasMany(AcademicAdvisingLog::class);
|
||||||
}
|
|
||||||
|
|
||||||
public function submissions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Submission::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attendances(): HasMany
|
public function attendances(): HasMany
|
||||||
@ -52,11 +48,6 @@ public function attendances(): HasMany
|
|||||||
return $this->hasMany(Attendance::class);
|
return $this->hasMany(Attendance::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tuitionInvoices(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TuitionInvoice::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function courseRegistrations(): HasMany
|
public function courseRegistrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CourseRegistration::class);
|
return $this->hasMany(CourseRegistration::class);
|
||||||
@ -67,13 +58,30 @@ public function courseRegistrationSubmissions(): HasMany
|
|||||||
return $this->hasMany(CourseRegistrationSubmission::class);
|
return $this->hasMany(CourseRegistrationSubmission::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function letterRequests(): HasMany
|
public function enrollments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(LetterRequest::class);
|
return $this->hasMany(ClassEnrollment::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function academicAdvisingLogs(): HasMany
|
public function submissions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AcademicAdvisingLog::class);
|
return $this->hasMany(Submission::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tuitionInvoices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TuitionInvoice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function letterRequests(): HasManyThrough
|
||||||
|
{
|
||||||
|
return $this->hasManyThrough(
|
||||||
|
LetterRequest::class,
|
||||||
|
User::class,
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'user_id',
|
||||||
|
'id',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,16 +21,16 @@ protected function casts(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function student(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Student::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function academicTerm(): BelongsTo
|
public function academicTerm(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(AcademicTerm::class);
|
return $this->belongsTo(AcademicTerm::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function student(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Student::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function payments(): HasMany
|
public function payments(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TuitionPayment::class, 'invoice_id');
|
return $this->hasMany(TuitionPayment::class, 'invoice_id');
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
@ -38,6 +39,11 @@ protected function fullName(): Attribute
|
|||||||
return Attribute::get(fn () => $this->profile?->full_name ?? $this->username);
|
return Attribute::get(fn () => $this->profile?->full_name ?? $this->username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function lecturer(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Lecturer::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function profile(): HasOne
|
public function profile(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(UserProfile::class);
|
return $this->hasOne(UserProfile::class);
|
||||||
@ -48,14 +54,19 @@ public function student(): HasOne
|
|||||||
return $this->hasOne(Student::class);
|
return $this->hasOne(Student::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function lecturer(): HasOne
|
public function courseRegistrationSubmissionLogs(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasOne(Lecturer::class);
|
return $this->hasMany(CourseRegistrationSubmissionLog::class, 'actor_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notifications(): HasMany
|
public function createdAnnouncements(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Notification::class);
|
return $this->hasMany(Announcement::class, 'created_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createdNotifications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Notification::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function feedbacks(): HasMany
|
public function feedbacks(): HasMany
|
||||||
@ -63,40 +74,100 @@ public function feedbacks(): HasMany
|
|||||||
return $this->hasMany(Feedback::class);
|
return $this->hasMany(Feedback::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isAdvisorOf(Student $student): bool
|
public function handledFeedbacks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasRole('dosen') && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
return $this->hasMany(Feedback::class, 'handled_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isKaprodiOf(Student $student): bool
|
public function letterRequests(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasRole('kaprodi') && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
|
return $this->hasMany(LetterRequest::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifications(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Notification::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processedLetterRequests(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(LetterRequest::class, 'processed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordedTuitionPayments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TuitionPayment::class, 'recorded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewedCourseRegistrationSubmissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CourseRegistrationSubmission::class, 'reviewed_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploads(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EditorUpload::class, 'uploaded_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gates page access: the student's academic advisor, the kaprodi of
|
* A dosen may only manage (update/delete) materials of classes they lecture.
|
||||||
* their department, or staff roles may view a student's KRS.
|
|
||||||
*/
|
*/
|
||||||
public function canAccessCourseRegistrationOf(Student $student): bool
|
public function canManageMaterial(Material $material): bool
|
||||||
{
|
{
|
||||||
if ($this->hasRole('dosen') || $this->hasRole('kaprodi')) {
|
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||||
return $this->isAdvisorOf($student) || $this->isKaprodiOf($student);
|
return $material->courseClass->lecturer_id === $this->lecturer?->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only the student's academic advisor may approve/reject; the kaprodi
|
* A dosen may only manage assignments (update/delete) and their submissions
|
||||||
* signs but does not decide. Other staff roles retain override access.
|
* (view/grade) for classes they lecture.
|
||||||
|
*/
|
||||||
|
public function canManageAssignment(Assignment $assignment): bool
|
||||||
|
{
|
||||||
|
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||||
|
return $assignment->courseClass->lecturer_id === $this->lecturer?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdvisorOf(Student $student): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDepartmentLeaderOf(Student $student): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole(UserRole::Kaprodi->value) && $this->lecturer && $student->department?->currentLeader?->lecturer_id === $this->lecturer->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates page access: the student's academic advisor, the department
|
||||||
|
* leader of their department, or staff roles may view a student's KRS.
|
||||||
|
*/
|
||||||
|
public function canAccessCourseRegistrationOf(Student $student): bool
|
||||||
|
{
|
||||||
|
if ($this->hasRole(UserRole::Dosen->value) || $this->hasRole(UserRole::Kaprodi->value)) {
|
||||||
|
return $this->isAdvisorOf($student) || $this->isDepartmentLeaderOf($student);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the student's academic advisor may approve/reject; the department
|
||||||
|
* leader signs but does not decide. Other staff roles retain override access.
|
||||||
*/
|
*/
|
||||||
public function canReviewCourseRegistrationOf(Student $student): bool
|
public function canReviewCourseRegistrationOf(Student $student): bool
|
||||||
{
|
{
|
||||||
if ($this->hasRole('dosen')) {
|
if ($this->hasRole(UserRole::Dosen->value)) {
|
||||||
return $this->isAdvisorOf($student);
|
return $this->isAdvisorOf($student);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasRole('kaprodi')) {
|
if ($this->hasRole(UserRole::Kaprodi->value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\AssignmentStatus;
|
use App\Enums\AssignmentStatus;
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
|
use App\Models\CourseRegistration;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class AssignmentService
|
class AssignmentService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NotificationService $notificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Assignment::query()
|
return Assignment::query()
|
||||||
->select(['id', 'course_class_id', 'title', 'description', 'deadline', 'status'])
|
|
||||||
->withCount([
|
->withCount([
|
||||||
'submissions',
|
'submissions',
|
||||||
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
|
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
|
||||||
])
|
])
|
||||||
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
||||||
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
|
->with([
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
'course:id,code,name',
|
||||||
|
'academicTerm:id,academic_year,semester,start_date,end_date',
|
||||||
|
'lecturer.user.profile',
|
||||||
|
])])
|
||||||
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
||||||
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
||||||
->where('student_id', $user->student?->id);
|
->where('student_id', $user->student?->id);
|
||||||
}]))
|
}]))
|
||||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||||
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
||||||
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
|
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
||||||
$q->whereHas('registrations', function ($q) use ($user) {
|
$q->whereHas('registrations', function ($q) use ($user) {
|
||||||
$q->where('student_id', $user->student?->id)
|
$q->where('student_id', $user->student?->id)
|
||||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||||
});
|
});
|
||||||
}))
|
}))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data, ?UploadedFile $file): Assignment
|
public function create(array $data, ?UploadedFile $file): Assignment
|
||||||
@ -53,17 +63,33 @@ public function create(array $data, ?UploadedFile $file): Assignment
|
|||||||
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$assignment->load('courseClass.course');
|
||||||
|
|
||||||
|
$recipientUserIds = CourseRegistration::query()
|
||||||
|
->where('course_class_id', $assignment->course_class_id)
|
||||||
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved))
|
||||||
|
->with('student:id,user_id')
|
||||||
|
->get()
|
||||||
|
->pluck('student.user_id');
|
||||||
|
|
||||||
|
$this->notificationService->sendToUsers(
|
||||||
|
$recipientUserIds,
|
||||||
|
'Tugas Baru',
|
||||||
|
"Tugas baru \"{$assignment->title}\" telah ditambahkan pada kelas {$assignment->courseClass->course->name}.",
|
||||||
|
);
|
||||||
|
|
||||||
return $assignment;
|
return $assignment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
|
public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment
|
||||||
{
|
{
|
||||||
$assignment->course_class_id = $data['course_class_id'];
|
$assignment->update([
|
||||||
$assignment->title = $data['title'];
|
'course_class_id' => $data['course_class_id'],
|
||||||
$assignment->description = $data['description'] ?? null;
|
'title' => $data['title'],
|
||||||
$assignment->deadline = $data['deadline'];
|
'description' => $data['description'] ?? null,
|
||||||
$assignment->status = $data['status'] ?? $assignment->status;
|
'deadline' => $data['deadline'],
|
||||||
$assignment->update();
|
'status' => $data['status'] ?? $assignment->status,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($file) {
|
if ($file) {
|
||||||
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
$assignment->addMedia($file)->toMediaCollection('assignment_attachment');
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses\Assignment;
|
||||||
|
|
||||||
use App\Enums\SubmissionStatus;
|
use App\Enums\SubmissionStatus;
|
||||||
use App\Models\Assignment;
|
use App\Models\Assignment;
|
||||||
@ -5,10 +5,8 @@
|
|||||||
use App\Enums\AttendanceStatus;
|
use App\Enums\AttendanceStatus;
|
||||||
use App\Models\Attendance;
|
use App\Models\Attendance;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Models\Student;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Collection as BaseCollection;
|
|
||||||
|
|
||||||
class AttendanceService
|
class AttendanceService
|
||||||
{
|
{
|
||||||
@ -113,35 +111,4 @@ public function deleteSession(int $courseClassId, int $meetingNumber): void
|
|||||||
->delete();
|
->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-class attendance summary for a student's own classes: every class
|
|
||||||
* they're enrolled in, with their recorded meetings and a present/total
|
|
||||||
* tally, even for classes with no attendance taken yet.
|
|
||||||
*/
|
|
||||||
public function forStudent(Student $student): BaseCollection
|
|
||||||
{
|
|
||||||
$enrollments = $student->enrollments()
|
|
||||||
->with([
|
|
||||||
'courseClass.course:id,code,name',
|
|
||||||
'courseClass.academicTerm:id,academic_year,semester,start_date,end_date',
|
|
||||||
])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$recordsByClass = Attendance::query()
|
|
||||||
->where('student_id', $student->id)
|
|
||||||
->orderBy('meeting_number')
|
|
||||||
->get(['course_class_id', 'meeting_number', 'date', 'status'])
|
|
||||||
->groupBy('course_class_id');
|
|
||||||
|
|
||||||
return $enrollments->map(function ($enrollment) use ($recordsByClass) {
|
|
||||||
$records = $recordsByClass->get($enrollment->course_class_id, new Collection);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'course_class' => $enrollment->courseClass,
|
|
||||||
'records' => $records->values(),
|
|
||||||
'present_count' => $records->where('status', AttendanceStatus::Present)->count(),
|
|
||||||
'total_count' => $records->count(),
|
|
||||||
];
|
|
||||||
})->values();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,29 +3,39 @@
|
|||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use App\Models\CourseRegistration;
|
||||||
use App\Models\Material;
|
use App\Models\Material;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class MaterialService
|
class MaterialService
|
||||||
{
|
{
|
||||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null): LengthAwarePaginator
|
public function __construct(
|
||||||
|
private readonly NotificationService $notificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Material::query()
|
return Material::query()
|
||||||
->select(['id', 'course_class_id', 'title', 'description', 'meeting_number'])
|
->with([
|
||||||
->with('courseClass.course:id,code,name')
|
'courseClass.course:id,code,name',
|
||||||
|
'courseClass.lecturer.user.profile',
|
||||||
|
])
|
||||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||||
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId)))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id)))
|
||||||
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) {
|
||||||
$q->whereHas('registrations', function ($q) use ($user) {
|
$q->whereHas('registrations', function ($q) use ($user) {
|
||||||
$q->where('student_id', $user->student?->id)
|
$q->where('student_id', $user->student?->id)
|
||||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||||
});
|
});
|
||||||
}))
|
}))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'course_class_id', 'title', 'description', 'meeting_number', 'created_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data, ?UploadedFile $file): Material
|
public function create(array $data, ?UploadedFile $file): Material
|
||||||
@ -41,16 +51,32 @@ public function create(array $data, ?UploadedFile $file): Material
|
|||||||
$material->addMedia($file)->toMediaCollection('materials');
|
$material->addMedia($file)->toMediaCollection('materials');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$material->load('courseClass.course');
|
||||||
|
|
||||||
|
$recipientUserIds = CourseRegistration::query()
|
||||||
|
->where('course_class_id', $material->course_class_id)
|
||||||
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved))
|
||||||
|
->with('student:id,user_id')
|
||||||
|
->get()
|
||||||
|
->pluck('student.user_id');
|
||||||
|
|
||||||
|
$this->notificationService->sendToUsers(
|
||||||
|
$recipientUserIds,
|
||||||
|
'Materi Baru',
|
||||||
|
"Materi baru \"{$material->title}\" telah ditambahkan pada kelas {$material->courseClass->course->name}.",
|
||||||
|
);
|
||||||
|
|
||||||
return $material;
|
return $material;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Material $material, array $data, ?UploadedFile $file): Material
|
public function update(Material $material, array $data, ?UploadedFile $file): Material
|
||||||
{
|
{
|
||||||
$material->course_class_id = $data['course_class_id'];
|
$material->update([
|
||||||
$material->title = $data['title'];
|
'course_class_id' => $data['course_class_id'],
|
||||||
$material->description = $data['description'] ?? null;
|
'title' => $data['title'],
|
||||||
$material->meeting_number = $data['meeting_number'] ?? null;
|
'description' => $data['description'] ?? null,
|
||||||
$material->update();
|
'meeting_number' => $data['meeting_number'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($file) {
|
if ($file) {
|
||||||
$material->addMedia($file)->toMediaCollection('materials');
|
$material->addMedia($file)->toMediaCollection('materials');
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Services\Admin\AcademicClasses;
|
namespace App\Services\Admin\AcademicClasses;
|
||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Models\Schedule;
|
use App\Models\Schedule;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -13,7 +14,6 @@ class ScheduleService
|
|||||||
public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection
|
public function all(User $user, ?int $academicTermId = null, ?int $departmentId = null, ?int $semesterNumber = null): Collection
|
||||||
{
|
{
|
||||||
return Schedule::query()
|
return Schedule::query()
|
||||||
->select(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link'])
|
|
||||||
->with([
|
->with([
|
||||||
'courseClass:id,course_id,lecturer_id,academic_term_id,method',
|
'courseClass:id,course_id,lecturer_id,academic_term_id,method',
|
||||||
'courseClass.course:id,code,name,department_id',
|
'courseClass.course:id,code,name,department_id',
|
||||||
@ -26,39 +26,14 @@ public function all(User $user, ?int $academicTermId = null, ?int $departmentId
|
|||||||
$q->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
|
$q->when($academicTermId, fn ($q) => $q->where('academic_term_id', $academicTermId))
|
||||||
->when($departmentId, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('department_id', $departmentId)))
|
->when($departmentId, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('department_id', $departmentId)))
|
||||||
->when($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber)))
|
->when($semesterNumber, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('semester_number', $semesterNumber)))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('registrations', function ($q) use ($user) {
|
||||||
$q->where('student_id', $user->student?->id)
|
$q->where('student_id', $user->student?->id)
|
||||||
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved));
|
||||||
}))
|
}))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id));
|
||||||
})
|
})
|
||||||
->orderBy('start_time')
|
->orderBy('start_time')
|
||||||
->get();
|
->get(['id', 'course_class_id', 'day_of_week', 'start_time', 'end_time', 'room', 'online_link']);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Course classes for the schedule form's picker, grouped by department and
|
|
||||||
* semester and ordered alphabetically to match the course-class picker
|
|
||||||
* pattern used elsewhere.
|
|
||||||
*/
|
|
||||||
public function courseClassOptions(): Collection
|
|
||||||
{
|
|
||||||
return CourseClass::query()
|
|
||||||
->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id'])
|
|
||||||
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
|
||||||
->join('departments', 'departments.id', '=', 'courses.department_id')
|
|
||||||
->with([
|
|
||||||
'course:id,code,name,department_id,semester_number',
|
|
||||||
'course.department:id,name',
|
|
||||||
'lecturer:id,user_id,lecturer_number',
|
|
||||||
'lecturer.user:id,username',
|
|
||||||
'lecturer.user.profile:id,user_id,full_name',
|
|
||||||
'academicTerm:id,academic_year,semester,start_date,end_date',
|
|
||||||
])
|
|
||||||
->orderBy('departments.name')
|
|
||||||
->orderBy('courses.semester_number')
|
|
||||||
->orderBy('courses.name')
|
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): Schedule
|
public function create(array $data): Schedule
|
||||||
@ -75,13 +50,14 @@ public function create(array $data): Schedule
|
|||||||
|
|
||||||
public function update(Schedule $schedule, array $data): Schedule
|
public function update(Schedule $schedule, array $data): Schedule
|
||||||
{
|
{
|
||||||
$schedule->course_class_id = $data['course_class_id'];
|
$schedule->update([
|
||||||
$schedule->day_of_week = $data['day_of_week'];
|
'course_class_id' => $data['course_class_id'],
|
||||||
$schedule->start_time = $data['start_time'];
|
'day_of_week' => $data['day_of_week'],
|
||||||
$schedule->end_time = $data['end_time'];
|
'start_time' => $data['start_time'],
|
||||||
$schedule->room = $data['room'] ?? null;
|
'end_time' => $data['end_time'],
|
||||||
$schedule->online_link = $data['online_link'] ?? null;
|
'room' => $data['room'] ?? null,
|
||||||
$schedule->update();
|
'online_link' => $data['online_link'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $schedule;
|
return $schedule;
|
||||||
}
|
}
|
||||||
@ -90,4 +66,28 @@ public function delete(Schedule $schedule): bool
|
|||||||
{
|
{
|
||||||
return $schedule->delete();
|
return $schedule->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Course classes for the schedule form's picker, grouped by department and
|
||||||
|
* semester and ordered alphabetically to match the course-class picker
|
||||||
|
* pattern used elsewhere.
|
||||||
|
*/
|
||||||
|
public function courseClassOptions(): Collection
|
||||||
|
{
|
||||||
|
return CourseClass::query()
|
||||||
|
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
||||||
|
->join('departments', 'departments.id', '=', 'courses.department_id')
|
||||||
|
->with([
|
||||||
|
'course:id,code,name,department_id,semester_number',
|
||||||
|
'course.department:id,name',
|
||||||
|
'lecturer:id,user_id,lecturer_number',
|
||||||
|
'lecturer.user:id,username',
|
||||||
|
'lecturer.user.profile:id,user_id,full_name',
|
||||||
|
'academicTerm:id,academic_year,semester,start_date,end_date',
|
||||||
|
])
|
||||||
|
->orderBy('departments.name')
|
||||||
|
->orderBy('courses.semester_number')
|
||||||
|
->orderBy('courses.name')
|
||||||
|
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,18 +41,10 @@ public function __construct()
|
|||||||
$this->sanitizer = new HtmlSanitizer($config);
|
$this->sanitizer = new HtmlSanitizer($config);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pendingCount(User $user): int
|
|
||||||
{
|
|
||||||
return Feedback::query()
|
|
||||||
->where('user_id', $user->id)
|
|
||||||
->where('status', '!=', FeedbackStatus::Resolved)
|
|
||||||
->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $type = null, ?string $status = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $type = null, ?string $status = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Feedback::query()
|
return Feedback::query()
|
||||||
->where('user_id', $user->id)
|
->when(! $user->can('update-feedback-status'), fn ($q) => $q->where('user_id', $user->id))
|
||||||
->with(['user.profile', 'handler.profile'])
|
->with(['user.profile', 'handler.profile'])
|
||||||
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('subject', 'like', "%{$search}%"))
|
||||||
->when($type, fn ($q) => $q->where('type', $type))
|
->when($type, fn ($q) => $q->where('type', $type))
|
||||||
@ -73,14 +65,20 @@ public function create(User $user, array $data): Feedback
|
|||||||
|
|
||||||
public function update(Feedback $feedback, array $data): Feedback
|
public function update(Feedback $feedback, array $data): Feedback
|
||||||
{
|
{
|
||||||
$feedback->type = $data['type'];
|
$feedback->update([
|
||||||
$feedback->subject = $data['subject'];
|
'type' => $data['type'],
|
||||||
$feedback->message = $this->sanitizer->sanitize($data['message']);
|
'subject' => $data['subject'],
|
||||||
$feedback->update();
|
'message' => $this->sanitizer->sanitize($data['message']),
|
||||||
|
]);
|
||||||
|
|
||||||
return $feedback;
|
return $feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function delete(Feedback $feedback): bool
|
||||||
|
{
|
||||||
|
return $feedback->delete();
|
||||||
|
}
|
||||||
|
|
||||||
public function updateStatus(Feedback $feedback, string $status): Feedback
|
public function updateStatus(Feedback $feedback, string $status): Feedback
|
||||||
{
|
{
|
||||||
$feedback->update([
|
$feedback->update([
|
||||||
@ -91,8 +89,26 @@ public function updateStatus(Feedback $feedback, string $status): Feedback
|
|||||||
return $feedback;
|
return $feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Feedback $feedback): bool
|
public function reply(Feedback $feedback, string $reply): Feedback
|
||||||
{
|
{
|
||||||
return $feedback->delete();
|
$feedback->update([
|
||||||
|
'reply' => $this->sanitizer->sanitize($reply),
|
||||||
|
'replied_at' => now(),
|
||||||
|
'handled_by' => auth()->id(),
|
||||||
|
'status' => FeedbackStatus::Resolved->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $feedback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingCount(User $user): int
|
||||||
|
{
|
||||||
|
if (! $user->can('update-feedback-status')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Feedback::query()
|
||||||
|
->where('status', FeedbackStatus::Submitted)
|
||||||
|
->count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
use App\Models\Student;
|
use App\Models\User;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
@ -13,15 +14,15 @@ public function __construct(
|
|||||||
private readonly NotificationService $notificationService,
|
private readonly NotificationService $notificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = ''): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Announcement::query()
|
return Announcement::query()
|
||||||
->select(['id', 'title', 'content', 'department_id', 'enrollment_year', 'created_by', 'created_at'])
|
->with(['creator.profile'])
|
||||||
->with(['department:id,name', 'creator.profile'])
|
|
||||||
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%"))
|
||||||
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Mahasiswa->value))
|
||||||
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereJsonContains('target_roles', UserRole::Dosen->value))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'title', 'content', 'target_roles', 'created_by', 'created_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): Announcement
|
public function create(array $data): Announcement
|
||||||
@ -29,15 +30,13 @@ public function create(array $data): Announcement
|
|||||||
$announcement = Announcement::create([
|
$announcement = Announcement::create([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'content' => $data['content'],
|
'content' => $data['content'],
|
||||||
'department_id' => $data['department_id'] ?? null,
|
'target_roles' => $data['target_roles'],
|
||||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$recipientUserIds = Student::query()
|
$recipientUserIds = User::query()
|
||||||
->when($announcement->department_id, fn ($q, $departmentId) => $q->where('department_id', $departmentId))
|
->whereHas('roles', fn ($q) => $q->whereIn('name', $announcement->target_roles))
|
||||||
->when($announcement->enrollment_year, fn ($q, $year) => $q->where('enrollment_year', $year))
|
->pluck('id');
|
||||||
->pluck('user_id');
|
|
||||||
|
|
||||||
$this->notificationService->sendToUsers(
|
$this->notificationService->sendToUsers(
|
||||||
$recipientUserIds,
|
$recipientUserIds,
|
||||||
@ -51,11 +50,11 @@ public function create(array $data): Announcement
|
|||||||
|
|
||||||
public function update(Announcement $announcement, array $data): Announcement
|
public function update(Announcement $announcement, array $data): Announcement
|
||||||
{
|
{
|
||||||
$announcement->title = $data['title'];
|
$announcement->update([
|
||||||
$announcement->content = $data['content'];
|
'title' => $data['title'],
|
||||||
$announcement->department_id = $data['department_id'] ?? null;
|
'content' => $data['content'],
|
||||||
$announcement->enrollment_year = $data['enrollment_year'] ?? null;
|
'target_roles' => $data['target_roles'],
|
||||||
$announcement->update();
|
]);
|
||||||
|
|
||||||
return $announcement;
|
return $announcement;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
@ -1,36 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage\CourseClass;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\CourseClass;
|
use App\Models\CourseClass;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class CourseClassService
|
class CourseClassService
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* When $user is a dosen, only their own assigned classes are returned.
|
private readonly AcademicTermService $academicTermService,
|
||||||
* When $user is a mahasiswa, only classes from their own department are returned.
|
) {}
|
||||||
*/
|
|
||||||
public function getAllForSelect(?User $user = null): Collection
|
|
||||||
{
|
|
||||||
return CourseClass::query()
|
|
||||||
->select(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id'])
|
|
||||||
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
|
||||||
->join('departments', 'departments.id', '=', 'courses.department_id')
|
|
||||||
->with([
|
|
||||||
'course:id,code,name,semester_number,department_id',
|
|
||||||
'course.department:id,name',
|
|
||||||
])
|
|
||||||
->when($user?->hasRole('dosen'), fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id))
|
|
||||||
->when($user?->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
|
||||||
->orderBy('departments.name')
|
|
||||||
->orderBy('courses.semester_number')
|
|
||||||
->orderBy('courses.name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?int $academicTermId = null, ?string $method = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
@ -40,7 +24,9 @@ public function paginated(int $perPage = 25, string $search = '', ?int $academic
|
|||||||
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
||||||
->withCount('enrollments')
|
->withCount('enrollments')
|
||||||
->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,semester,start_date,end_date'])
|
->with(['course:id,code,name,department_id,semester_number', 'lecturer.user.profile', 'academicTerm:id,academic_year,semester,start_date,end_date'])
|
||||||
->when($search, fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%")))
|
->when($search, fn ($q) => $q->where(fn ($q) => $q->whereHas('course', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||||
|
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%")))))
|
||||||
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
|
->when($academicTermId, fn ($q) => $q->where('course_classes.academic_term_id', $academicTermId))
|
||||||
->when($method, fn ($q) => $q->where('course_classes.method', $method))
|
->when($method, fn ($q) => $q->where('course_classes.method', $method))
|
||||||
->orderByDesc('academic_terms.start_date')
|
->orderByDesc('academic_terms.start_date')
|
||||||
@ -65,11 +51,12 @@ public function createMany(array $data): Collection
|
|||||||
|
|
||||||
public function update(CourseClass $courseClass, array $data): CourseClass
|
public function update(CourseClass $courseClass, array $data): CourseClass
|
||||||
{
|
{
|
||||||
$courseClass->course_id = $data['course_id'];
|
$courseClass->update([
|
||||||
$courseClass->lecturer_id = $data['lecturer_id'];
|
'course_id' => $data['course_id'],
|
||||||
$courseClass->academic_term_id = $data['academic_term_id'];
|
'lecturer_id' => $data['lecturer_id'],
|
||||||
$courseClass->method = $data['method'] ?? null;
|
'academic_term_id' => $data['academic_term_id'],
|
||||||
$courseClass->update();
|
'method' => $data['method'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $courseClass;
|
return $courseClass;
|
||||||
}
|
}
|
||||||
@ -121,4 +108,29 @@ public function duplicateFromTerm(int $sourceAcademicTermId, int $targetAcademic
|
|||||||
return ['created' => $created, 'skipped' => $skipped];
|
return ['created' => $created, 'skipped' => $skipped];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When $user is a dosen, only their own classes in the active academic term are returned.
|
||||||
|
* When $user is a mahasiswa, only classes from their own department are returned.
|
||||||
|
*/
|
||||||
|
public function getAllForSelect(User $user): Collection
|
||||||
|
{
|
||||||
|
$isLecturer = $user->hasRole(UserRole::Dosen->value);
|
||||||
|
$activeAcademicTermId = $isLecturer ? $this->academicTermService->getActive()?->id : null;
|
||||||
|
|
||||||
|
return CourseClass::query()
|
||||||
|
->join('courses', 'courses.id', '=', 'course_classes.course_id')
|
||||||
|
->join('departments', 'departments.id', '=', 'courses.department_id')
|
||||||
|
->with([
|
||||||
|
'course:id,code,name,semester_number,department_id',
|
||||||
|
'course.department:id,name',
|
||||||
|
])
|
||||||
|
->when($isLecturer, fn ($q) => $q->where('course_classes.lecturer_id', $user->lecturer?->id)
|
||||||
|
->where('course_classes.academic_term_id', $activeAcademicTermId))
|
||||||
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
||||||
|
->orderBy('departments.name')
|
||||||
|
->orderBy('courses.semester_number')
|
||||||
|
->orderBy('courses.name')
|
||||||
|
->get(['course_classes.id', 'course_classes.course_id', 'course_classes.lecturer_id', 'course_classes.academic_term_id']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Enums\RegistrationStatus;
|
use App\Enums\RegistrationStatus;
|
||||||
use App\Enums\StudentStatus;
|
use App\Enums\StudentStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\AcademicTerm;
|
use App\Models\AcademicTerm;
|
||||||
use App\Models\ClassEnrollment;
|
use App\Models\ClassEnrollment;
|
||||||
use App\Models\Course;
|
use App\Models\Course;
|
||||||
@ -12,6 +13,7 @@
|
|||||||
use App\Models\CourseRegistrationSubmission;
|
use App\Models\CourseRegistrationSubmission;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use App\Models\Student;
|
use App\Models\Student;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Admin\Master\AcademicTermService;
|
use App\Services\Admin\Master\AcademicTermService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@ -24,24 +26,13 @@ public function __construct(
|
|||||||
private readonly AcademicTermService $academicTermService,
|
private readonly AcademicTermService $academicTermService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||||
* @return Collection<int, Department>
|
|
||||||
*/
|
|
||||||
public function departmentSummary(): Collection
|
|
||||||
{
|
{
|
||||||
return Department::query()
|
$isDepartmentLeader = $user->hasRole(UserRole::Kaprodi->value);
|
||||||
->select('id', 'name')
|
$advisorLecturerId = ! $isDepartmentLeader && $user->hasRole(UserRole::Dosen->value) ? $user->lecturer?->id : null;
|
||||||
->orderBy('name')
|
$ledDepartmentIds = $isDepartmentLeader ? $user->ledDepartmentIds() : null;
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int>|null $ledDepartmentIds Restricts results to a kaprodi's own department(s).
|
|
||||||
*/
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $advisorLecturerId = null, ?array $ledDepartmentIds = null): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$paginator = Student::query()
|
$paginator = Student::query()
|
||||||
->select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id'])
|
|
||||||
->join('departments', 'departments.id', '=', 'students.department_id')
|
->join('departments', 'departments.id', '=', 'students.department_id')
|
||||||
->join('users', 'users.id', '=', 'students.user_id')
|
->join('users', 'users.id', '=', 'students.user_id')
|
||||||
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
||||||
@ -54,7 +45,7 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
|||||||
->with(['user.profile', 'department:id,name'])
|
->with(['user.profile', 'department:id,name'])
|
||||||
->orderBy('departments.name')
|
->orderBy('departments.name')
|
||||||
->orderBy('user_profiles.full_name')
|
->orderBy('user_profiles.full_name')
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['students.id', 'students.user_id', 'students.student_number', 'students.department_id']);
|
||||||
|
|
||||||
return $paginator->through(fn (Student $student) => [
|
return $paginator->through(fn (Student $student) => [
|
||||||
'student_id' => $student->id,
|
'student_id' => $student->id,
|
||||||
@ -219,11 +210,11 @@ public function saveRegistrations(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function signAsKaprodi(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
|
public function signAsDepartmentLeader(CourseRegistrationSubmission $submission, UploadedFile $signature): CourseRegistrationSubmission
|
||||||
{
|
{
|
||||||
$this->assertSignable($submission);
|
$this->assertSignable($submission);
|
||||||
|
|
||||||
$submission->addMedia($signature)->toMediaCollection('kaprodi_signature');
|
$submission->addMedia($signature)->toMediaCollection('department_leader_signature');
|
||||||
|
|
||||||
return $submission;
|
return $submission;
|
||||||
}
|
}
|
||||||
@ -246,7 +237,7 @@ private function assertSignable(CourseRegistrationSubmission $submission): void
|
|||||||
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
|
public function approve(CourseRegistrationSubmission $submission): CourseRegistrationSubmission
|
||||||
{
|
{
|
||||||
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
|
abort_unless($submission->status === RegistrationStatus::Submitted, 403);
|
||||||
abort_if($submission->kaprodi_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
|
abort_if($submission->department_leader_signature_url === null, 422, 'Ketua Program Studi belum menandatangani KRS ini.');
|
||||||
abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.');
|
abort_if($submission->advisor_signature_url === null, 422, 'Silakan tanda tangani KRS ini terlebih dahulu.');
|
||||||
|
|
||||||
return DB::transaction(function () use ($submission) {
|
return DB::transaction(function () use ($submission) {
|
||||||
@ -299,4 +290,14 @@ public function reject(CourseRegistrationSubmission $submission, string $reason)
|
|||||||
return $submission;
|
return $submission;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Department>
|
||||||
|
*/
|
||||||
|
public function departmentSummary(): Collection
|
||||||
|
{
|
||||||
|
return Department::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,30 +9,14 @@
|
|||||||
|
|
||||||
class AcademicTermService
|
class AcademicTermService
|
||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
|
||||||
{
|
|
||||||
return AcademicTerm::select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])->latest()->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The academic term currently marked active, falling back to the one with
|
|
||||||
* the latest start date if none is explicitly active.
|
|
||||||
*/
|
|
||||||
public function getActive(): ?AcademicTerm
|
|
||||||
{
|
|
||||||
return AcademicTerm::where('is_active', true)->first()
|
|
||||||
?? AcademicTerm::orderByDesc('start_date')->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?string $semester = null, ?bool $isActive = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return AcademicTerm::query()
|
return AcademicTerm::query()
|
||||||
->select(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters'])
|
->when($search, fn ($q) => $q->where('academic_year', 'like', "%{$search}%"))
|
||||||
->when($search, fn($q) => $q->where('academic_year', 'like', "%{$search}%"))
|
->when($semester, fn ($q) => $q->where('semester', $semester))
|
||||||
->when($semester, fn($q) => $q->where('semester', $semester))
|
->when($isActive !== null, fn ($q) => $q->where('is_active', $isActive))
|
||||||
->when($isActive !== null, fn($q) => $q->where('is_active', $isActive))
|
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): AcademicTerm
|
public function create(array $data): AcademicTerm
|
||||||
@ -45,13 +29,14 @@ public function create(array $data): AcademicTerm
|
|||||||
|
|
||||||
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
|
public function update(AcademicTerm $academicTerm, array $data): AcademicTerm
|
||||||
{
|
{
|
||||||
$academicTerm->academic_year = $data['academic_year'];
|
$academicTerm->update([
|
||||||
$academicTerm->semester = $data['semester'];
|
'academic_year' => $data['academic_year'],
|
||||||
$academicTerm->start_date = $data['start_date'];
|
'semester' => $data['semester'],
|
||||||
$academicTerm->end_date = $data['end_date'];
|
'start_date' => $data['start_date'],
|
||||||
$academicTerm->is_active = $data['is_active'];
|
'end_date' => $data['end_date'],
|
||||||
$academicTerm->open_semesters = $this->normalizeOpenSemesters($data['open_semesters'] ?? []);
|
'is_active' => $data['is_active'],
|
||||||
$academicTerm->update();
|
'open_semesters' => $this->normalizeOpenSemesters($data['open_semesters'] ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
return $academicTerm;
|
return $academicTerm;
|
||||||
}
|
}
|
||||||
@ -72,6 +57,21 @@ public function updateActiveStatus(AcademicTerm $academicTerm, bool $isActive):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAllForSelect(): Collection
|
||||||
|
{
|
||||||
|
return AcademicTerm::latest()->get(['id', 'academic_year', 'semester', 'start_date', 'end_date', 'is_active', 'open_semesters']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The academic term currently marked active, falling back to the one with
|
||||||
|
* the latest start date if none is explicitly active.
|
||||||
|
*/
|
||||||
|
public function getActive(): ?AcademicTerm
|
||||||
|
{
|
||||||
|
return AcademicTerm::where('is_active', true)->first()
|
||||||
|
?? AcademicTerm::orderByDesc('start_date')->first();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, mixed> $openSemesters
|
* @param array<int, mixed> $openSemesters
|
||||||
* @return array<int, int>
|
* @return array<int, int>
|
||||||
|
|||||||
@ -2,52 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Master;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Course;
|
use App\Models\Course;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class CourseService
|
class CourseService
|
||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Course::query()
|
return Course::query()
|
||||||
->select(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number'])
|
|
||||||
->join('departments', 'departments.id', '=', 'courses.department_id')
|
|
||||||
->with('department:id,name')
|
|
||||||
->orderBy('departments.name')
|
|
||||||
->orderBy('courses.semester_number')
|
|
||||||
->orderBy('courses.name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSemesterNumbers(): array
|
|
||||||
{
|
|
||||||
return Course::query()
|
|
||||||
->select('semester_number')
|
|
||||||
->whereNotNull('semester_number')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('semester_number')
|
|
||||||
->pluck('semester_number')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null, ?int $semesterNumber = null): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return Course::query()
|
|
||||||
->select(['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number'])
|
|
||||||
->join('departments', 'departments.id', '=', 'courses.department_id')
|
->join('departments', 'departments.id', '=', 'courses.department_id')
|
||||||
->with('department:id,name')
|
->with('department:id,name')
|
||||||
->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('courses.name', 'like', "%{$search}%")->orWhere('courses.code', 'like', "%{$search}%"))
|
||||||
->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId))
|
->when($departmentId, fn ($q) => $q->where('courses.department_id', $departmentId))
|
||||||
->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
|
->when($semesterNumber, fn ($q) => $q->where('courses.semester_number', $semesterNumber))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->where('courses.department_id', $user->student?->department_id))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereIn('courses.department_id', $user->lecturer?->departments()->pluck('departments.id') ?? []))
|
||||||
->orderBy('departments.name')
|
->orderBy('departments.name')
|
||||||
->orderBy('courses.semester_number')
|
->orderBy('courses.semester_number')
|
||||||
->orderBy('courses.name')
|
->orderBy('courses.name')
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['courses.id', 'courses.code', 'courses.name', 'courses.credits', 'courses.department_id', 'courses.semester_number']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): Course
|
public function create(array $data): Course
|
||||||
@ -57,12 +33,13 @@ public function create(array $data): Course
|
|||||||
|
|
||||||
public function update(Course $course, array $data): Course
|
public function update(Course $course, array $data): Course
|
||||||
{
|
{
|
||||||
$course->code = $data['code'];
|
$course->update([
|
||||||
$course->name = $data['name'];
|
'code' => $data['code'],
|
||||||
$course->credits = $data['credits'];
|
'name' => $data['name'],
|
||||||
$course->department_id = $data['department_id'];
|
'credits' => $data['credits'],
|
||||||
$course->semester_number = $data['semester_number'] ?? null;
|
'department_id' => $data['department_id'],
|
||||||
$course->update();
|
'semester_number' => $data['semester_number'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $course;
|
return $course;
|
||||||
}
|
}
|
||||||
@ -71,4 +48,25 @@ public function delete(Course $course): bool
|
|||||||
{
|
{
|
||||||
return $course->delete();
|
return $course->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAllForSelect(): Collection
|
||||||
|
{
|
||||||
|
return Course::query()
|
||||||
|
->join('departments', 'departments.id', '=', 'courses.department_id')
|
||||||
|
->with('department:id,name')
|
||||||
|
->orderBy('departments.name')
|
||||||
|
->orderBy('courses.semester_number')
|
||||||
|
->orderBy('courses.name')
|
||||||
|
->get(['courses.id', 'courses.code', 'courses.name', 'courses.department_id', 'courses.semester_number']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSemesterNumbers(): array
|
||||||
|
{
|
||||||
|
return Course::query()
|
||||||
|
->whereNotNull('semester_number')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('semester_number')
|
||||||
|
->pluck('semester_number')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Master;
|
namespace App\Services\Admin\Master;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Department;
|
use App\Models\Department;
|
||||||
use App\Models\DepartmentLeadership;
|
use App\Models\DepartmentLeadership;
|
||||||
use App\Models\Lecturer;
|
use App\Models\Lecturer;
|
||||||
@ -10,19 +11,17 @@
|
|||||||
|
|
||||||
class DepartmentService
|
class DepartmentService
|
||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
|
||||||
{
|
|
||||||
return Department::select(['id', 'code', 'name', 'degree_level'])->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = ''): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = ''): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Department::query()
|
return Department::query()
|
||||||
->select(['id', 'code', 'name', 'degree_level'])
|
->with([
|
||||||
->with(['currentLeader.lecturer.user.profile'])
|
'currentLeader.lecturer.user.profile',
|
||||||
|
'leaderships' => fn ($q) => $q->orderByRaw('ended_at IS NULL DESC')->orderByDesc('started_at'),
|
||||||
|
'leaderships.lecturer.user.profile',
|
||||||
|
])
|
||||||
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('code', 'like', "%{$search}%"))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'code', 'name', 'degree_level']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): Department
|
public function create(array $data): Department
|
||||||
@ -40,16 +39,27 @@ public function create(array $data): Department
|
|||||||
|
|
||||||
public function update(Department $department, array $data): Department
|
public function update(Department $department, array $data): Department
|
||||||
{
|
{
|
||||||
$department->code = $data['code'];
|
$department->update([
|
||||||
$department->name = $data['name'];
|
'code' => $data['code'],
|
||||||
$department->degree_level = $data['degree_level'] ?? null;
|
'name' => $data['name'],
|
||||||
$department->update();
|
'degree_level' => $data['degree_level'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
|
$this->syncLeadership($department, $data['lecturer_id'] ?? null, $data['leadership_started_at'] ?? null);
|
||||||
|
|
||||||
return $department;
|
return $department;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function delete(Department $department): bool
|
||||||
|
{
|
||||||
|
return $department->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllForSelect(): Collection
|
||||||
|
{
|
||||||
|
return Department::get(['id', 'code', 'name', 'degree_level']);
|
||||||
|
}
|
||||||
|
|
||||||
private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void
|
private function syncLeadership(Department $department, ?int $lecturerId, ?string $startedAt): void
|
||||||
{
|
{
|
||||||
$currentLeader = DepartmentLeadership::query()
|
$currentLeader = DepartmentLeadership::query()
|
||||||
@ -90,8 +100,8 @@ private function grantKaprodiRole(int $lecturerId): void
|
|||||||
{
|
{
|
||||||
$user = Lecturer::find($lecturerId)?->user;
|
$user = Lecturer::find($lecturerId)?->user;
|
||||||
|
|
||||||
if ($user && ! $user->hasRole('kaprodi')) {
|
if ($user && ! $user->hasRole(UserRole::Kaprodi->value)) {
|
||||||
$user->assignRole('kaprodi');
|
$user->assignRole(UserRole::Kaprodi->value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,12 +113,7 @@ private function revokeKaprodiRoleIfNoLongerLeading(int $lecturerId): void
|
|||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if (! $stillLeadsAnyDepartment) {
|
if (! $stillLeadsAnyDepartment) {
|
||||||
Lecturer::find($lecturerId)?->user?->removeRole('kaprodi');
|
Lecturer::find($lecturerId)?->user?->removeRole(UserRole::Kaprodi->value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Department $department): bool
|
|
||||||
{
|
|
||||||
return $department->delete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Services;
|
namespace App\Services\Admin\Services;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\AcademicAdvisingLog;
|
use App\Models\AcademicAdvisingLog;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class AcademicAdvisingLogService
|
class AcademicAdvisingLogService
|
||||||
{
|
{
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return AcademicAdvisingLog::query()
|
return AcademicAdvisingLog::query()
|
||||||
->select(['id', 'lecturer_id', 'topic', 'notes', 'created_at'])
|
|
||||||
->with([
|
->with([
|
||||||
|
'students' => function ($q) use ($user) {
|
||||||
|
if ($user->hasRole(UserRole::Mahasiswa->value)) {
|
||||||
|
$q->where('students.id', $user->student?->id);
|
||||||
|
}
|
||||||
|
},
|
||||||
'students.user.profile',
|
'students.user.profile',
|
||||||
'students.department',
|
'students.department',
|
||||||
'lecturer.user.profile',
|
'lecturer.user.profile',
|
||||||
@ -23,9 +27,10 @@ public function paginated(int $perPage = 25, string $search = '', ?int $lecturer
|
|||||||
->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
||||||
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
||||||
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
|
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
|
||||||
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
|
->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
|
||||||
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('students', fn ($q) => $q->where('students.id', $user->student?->id)))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'lecturer_id', 'topic', 'notes', 'created_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): AcademicAdvisingLog
|
public function create(array $data): AcademicAdvisingLog
|
||||||
@ -43,10 +48,11 @@ public function create(array $data): AcademicAdvisingLog
|
|||||||
|
|
||||||
public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
|
public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
|
||||||
{
|
{
|
||||||
$log->lecturer_id = $data['lecturer_id'];
|
$log->update([
|
||||||
$log->topic = $data['topic'] ?? null;
|
'lecturer_id' => $data['lecturer_id'],
|
||||||
$log->notes = $data['notes'] ?? null;
|
'topic' => $data['topic'] ?? null,
|
||||||
$log->update();
|
'notes' => $data['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
$log->students()->sync($data['student_ids']);
|
$log->students()->sync($data['student_ids']);
|
||||||
|
|
||||||
|
|||||||
@ -3,26 +3,26 @@
|
|||||||
namespace App\Services\Admin\Services;
|
namespace App\Services\Admin\Services;
|
||||||
|
|
||||||
use App\Enums\LetterStatus;
|
use App\Enums\LetterStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\LetterRequest;
|
use App\Models\LetterRequest;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class LetterRequestService
|
class LetterRequestService
|
||||||
{
|
{
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
return LetterRequest::query()
|
return LetterRequest::query()
|
||||||
->select(['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at'])
|
->with(['user.profile', 'user.student.department', 'user.lecturer.departments', 'processor.profile'])
|
||||||
->with(['user.profile', 'user.student.department', 'processor.profile'])
|
|
||||||
->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%")
|
->when($search, fn ($q) => $q->where('letter_type', 'like', "%{$search}%")
|
||||||
->orWhereHas('user', fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
->orWhereHas('user', fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
||||||
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"))))
|
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"))
|
||||||
|
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"))))
|
||||||
->when($status, fn ($q) => $q->where('status', $status))
|
->when($status, fn ($q) => $q->where('status', $status))
|
||||||
->when($user->hasRole('mahasiswa'), fn ($q) => $q->where('user_id', $user->id))
|
->when($user->hasRole(UserRole::Mahasiswa->value) || $user->hasRole(UserRole::Dosen->value), fn ($q) => $q->where('user_id', $user->id))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'user_id', 'letter_type', 'purpose', 'status', 'processed_by', 'submitted_at', 'completed_at']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): LetterRequest
|
public function create(array $data): LetterRequest
|
||||||
@ -38,9 +38,10 @@ public function create(array $data): LetterRequest
|
|||||||
|
|
||||||
public function update(LetterRequest $letterRequest, array $data): LetterRequest
|
public function update(LetterRequest $letterRequest, array $data): LetterRequest
|
||||||
{
|
{
|
||||||
$letterRequest->letter_type = $data['letter_type'];
|
$letterRequest->update([
|
||||||
$letterRequest->purpose = $data['purpose'] ?? null;
|
'letter_type' => $data['letter_type'],
|
||||||
$letterRequest->update();
|
'purpose' => $data['purpose'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $letterRequest;
|
return $letterRequest;
|
||||||
}
|
}
|
||||||
@ -54,12 +55,13 @@ public function updateStatus(LetterRequest $letterRequest, ?string $status, ?Upl
|
|||||||
{
|
{
|
||||||
$status = $result ? LetterStatus::Completed->value : $status;
|
$status = $result ? LetterStatus::Completed->value : $status;
|
||||||
|
|
||||||
$letterRequest->status = $status;
|
$letterRequest->update([
|
||||||
$letterRequest->processed_by = $status !== LetterStatus::Submitted->value ? auth()->id() : null;
|
'status' => $status,
|
||||||
$letterRequest->completed_at = $status === LetterStatus::Completed->value
|
'processed_by' => $status !== LetterStatus::Submitted->value ? auth()->id() : null,
|
||||||
? ($letterRequest->completed_at ?? now())
|
'completed_at' => $status === LetterStatus::Completed->value
|
||||||
: null;
|
? ($letterRequest->completed_at ?? now())
|
||||||
$letterRequest->update();
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$letterRequest->addMedia($result)->toMediaCollection('letter_result');
|
$letterRequest->addMedia($result)->toMediaCollection('letter_result');
|
||||||
|
|||||||
@ -2,21 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Users;
|
namespace App\Services\Admin\Users;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
|
||||||
class AdministratorService
|
class AdministratorService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var list<UserRole>
|
||||||
|
*/
|
||||||
|
private const ROLES = [UserRole::StaffAdmin, UserRole::StaffKeuangan];
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return User::select(['id', 'username', 'email', 'is_active'])
|
return User::with([
|
||||||
->with([
|
'profile:id,user_id,full_name,phone_number,gender',
|
||||||
'profile:id,user_id,full_name,phone_number,gender',
|
'roles:id,name',
|
||||||
'roles:id,name',
|
])
|
||||||
])
|
->whereHas('roles', fn ($q) => $q->whereIn('name', array_column(self::ROLES, 'value')))
|
||||||
->whereHas('roles', fn ($q) => $q->whereIn('name', ['staff-admin', 'staff-keuangan']))
|
|
||||||
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
||||||
$query->where('username', 'like', "%{$search}%")
|
$query->where('username', 'like', "%{$search}%")
|
||||||
->orWhere('email', 'like', "%{$search}%")
|
->orWhere('email', 'like', "%{$search}%")
|
||||||
@ -24,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', ?string $gende
|
|||||||
}))
|
}))
|
||||||
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): User
|
public function create(array $data): User
|
||||||
@ -93,4 +100,12 @@ public function updateUserStatus(User $user, bool $isActive): void
|
|||||||
{
|
{
|
||||||
$user->update(['is_active' => $isActive]);
|
$user->update(['is_active' => $isActive]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Role>
|
||||||
|
*/
|
||||||
|
public function availableRoles(): Collection
|
||||||
|
{
|
||||||
|
return Role::whereIn('name', array_column(self::ROLES, 'value'))->get(['id', 'name']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Users;
|
namespace App\Services\Admin\Users;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Lecturer;
|
use App\Models\Lecturer;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -12,51 +13,11 @@
|
|||||||
|
|
||||||
class LecturerService
|
class LecturerService
|
||||||
{
|
{
|
||||||
public function getAllForSelect(): Collection
|
|
||||||
{
|
|
||||||
return Lecturer::select(['lecturers.id', 'lecturers.user_id', 'lecturers.lecturer_number'])
|
|
||||||
->join('users', 'users.id', '=', 'lecturers.user_id')
|
|
||||||
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
|
||||||
->with([
|
|
||||||
'user:id,username',
|
|
||||||
'user.profile:id,user_id,full_name',
|
|
||||||
'departments:id,name',
|
|
||||||
])
|
|
||||||
->orderBy('user_profiles.full_name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->filteredQuery($search, $gender, $departmentId)
|
return $this->filteredQuery($search, $gender, $departmentId)
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||||
}
|
|
||||||
|
|
||||||
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null): Collection
|
|
||||||
{
|
|
||||||
return $this->filteredQuery($search, $gender, $departmentId)
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredQuery(string $search, ?string $gender, ?int $departmentId): Builder
|
|
||||||
{
|
|
||||||
return User::select(['id', 'username', 'email', 'is_active'])
|
|
||||||
->with([
|
|
||||||
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
|
|
||||||
'lecturer:id,user_id,lecturer_number',
|
|
||||||
'lecturer.departments:id,name',
|
|
||||||
])
|
|
||||||
->whereHas('roles', fn ($q) => $q->where('name', 'dosen'))
|
|
||||||
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
|
||||||
$query->where('username', 'like', "%{$search}%")
|
|
||||||
->orWhere('email', 'like', "%{$search}%")
|
|
||||||
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
|
||||||
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
|
|
||||||
}))
|
|
||||||
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
|
||||||
->when($departmentId, fn ($q) => $q->whereHas('lecturer.departments', fn ($q) => $q->where('departments.id', $departmentId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): User
|
public function create(array $data): User
|
||||||
@ -68,7 +29,7 @@ public function create(array $data): User
|
|||||||
'password' => Hash::make(config('app.default_password')),
|
'password' => Hash::make(config('app.default_password')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->assignRole('dosen');
|
$user->assignRole(UserRole::Dosen->value);
|
||||||
|
|
||||||
$user->profile()->create([
|
$user->profile()->create([
|
||||||
'full_name' => $data['full_name'],
|
'full_name' => $data['full_name'],
|
||||||
@ -136,4 +97,42 @@ public function updateUserStatus(User $user, bool $isActive): void
|
|||||||
{
|
{
|
||||||
$user->update(['is_active' => $isActive]);
|
$user->update(['is_active' => $isActive]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null): Collection
|
||||||
|
{
|
||||||
|
return $this->filteredQuery($search, $gender, $departmentId)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get(['id', 'username', 'email', 'is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllForSelect(): Collection
|
||||||
|
{
|
||||||
|
return Lecturer::join('users', 'users.id', '=', 'lecturers.user_id')
|
||||||
|
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
||||||
|
->with([
|
||||||
|
'user:id,username',
|
||||||
|
'user.profile:id,user_id,full_name',
|
||||||
|
'departments:id,name',
|
||||||
|
])
|
||||||
|
->orderBy('user_profiles.full_name')
|
||||||
|
->get(['lecturers.id', 'lecturers.user_id', 'lecturers.lecturer_number']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredQuery(string $search, ?string $gender, ?int $departmentId): Builder
|
||||||
|
{
|
||||||
|
return User::with([
|
||||||
|
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
|
||||||
|
'lecturer:id,user_id,lecturer_number',
|
||||||
|
'lecturer.departments:id,name',
|
||||||
|
])
|
||||||
|
->whereHas('roles', fn ($q) => $q->where('name', UserRole::Dosen->value))
|
||||||
|
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
||||||
|
$query->where('username', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
||||||
|
->orWhereHas('lecturer', fn ($q) => $q->where('lecturer_number', 'like', "%{$search}%"));
|
||||||
|
}))
|
||||||
|
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
||||||
|
->when($departmentId, fn ($q) => $q->whereHas('lecturer.departments', fn ($q) => $q->where('departments.id', $departmentId)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services\Admin\Users;
|
namespace App\Services\Admin\Users;
|
||||||
|
|
||||||
|
use App\Enums\StudentStatus;
|
||||||
|
use App\Enums\UserRole;
|
||||||
use App\Models\Student;
|
use App\Models\Student;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -12,67 +14,11 @@
|
|||||||
|
|
||||||
class StudentService
|
class StudentService
|
||||||
{
|
{
|
||||||
public function getAllForSelect(?string $status = null): Collection
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Student::select(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester'])
|
return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
|
||||||
->join('departments', 'departments.id', '=', 'students.department_id')
|
|
||||||
->join('users', 'users.id', '=', 'students.user_id')
|
|
||||||
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
|
||||||
->with([
|
|
||||||
'user:id,username',
|
|
||||||
'user.profile:id,user_id,full_name',
|
|
||||||
'department:id,name',
|
|
||||||
])
|
|
||||||
->when($status, fn ($q) => $q->where('students.status', $status))
|
|
||||||
->orderBy('departments.name')
|
|
||||||
->orderBy('students.current_semester')
|
|
||||||
->orderBy('user_profiles.full_name')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEnrollmentYears(): array
|
|
||||||
{
|
|
||||||
return Student::query()
|
|
||||||
->select('enrollment_year')
|
|
||||||
->distinct()
|
|
||||||
->orderByDesc('enrollment_year')
|
|
||||||
->pluck('enrollment_year')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
return $this->filteredQuery($search, $gender, $departmentId, $status, $enrollmentYear)
|
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage, ['id', 'username', 'email', 'is_active']);
|
||||||
}
|
|
||||||
|
|
||||||
public function forExport(string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): Collection
|
|
||||||
{
|
|
||||||
return $this->filteredQuery($search, $gender, $departmentId, $status, $enrollmentYear)
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredQuery(string $search, ?string $gender, ?int $departmentId, ?string $status, ?int $enrollmentYear = null): Builder
|
|
||||||
{
|
|
||||||
return User::select(['id', 'username', 'email', 'is_active'])
|
|
||||||
->with([
|
|
||||||
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
|
|
||||||
'student:id,user_id,student_number,department_id,enrollment_year,current_semester,status',
|
|
||||||
'student.department:id,name',
|
|
||||||
])
|
|
||||||
->whereHas('roles', fn ($q) => $q->where('name', 'mahasiswa'))
|
|
||||||
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
|
||||||
$query->where('username', 'like', "%{$search}%")
|
|
||||||
->orWhere('email', 'like', "%{$search}%")
|
|
||||||
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
|
||||||
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"));
|
|
||||||
}))
|
|
||||||
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
|
||||||
->when($departmentId, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('department_id', $departmentId)))
|
|
||||||
->when($status, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('status', $status)))
|
|
||||||
->when($enrollmentYear, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('enrollment_year', $enrollmentYear)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data): User
|
public function create(array $data): User
|
||||||
@ -84,7 +30,7 @@ public function create(array $data): User
|
|||||||
'password' => Hash::make(config('app.default_password')),
|
'password' => Hash::make(config('app.default_password')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->assignRole('mahasiswa');
|
$user->assignRole(UserRole::Mahasiswa->value);
|
||||||
|
|
||||||
$user->profile()->create([
|
$user->profile()->create([
|
||||||
'full_name' => $data['full_name'],
|
'full_name' => $data['full_name'],
|
||||||
@ -101,7 +47,7 @@ public function create(array $data): User
|
|||||||
'enrollment_year' => $data['enrollment_year'],
|
'enrollment_year' => $data['enrollment_year'],
|
||||||
'current_semester' => $data['current_semester'],
|
'current_semester' => $data['current_semester'],
|
||||||
'academic_advisor_id' => $data['academic_advisor_id'],
|
'academic_advisor_id' => $data['academic_advisor_id'],
|
||||||
'status' => 'active',
|
'status' => StudentStatus::Active,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
@ -162,4 +108,62 @@ public function updateUserStatus(User $user, bool $isActive): void
|
|||||||
{
|
{
|
||||||
$user->update(['is_active' => $isActive]);
|
$user->update(['is_active' => $isActive]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function forExport(User $user, string $search = '', ?string $gender = null, ?int $departmentId = null, ?string $status = null, ?int $enrollmentYear = null): Collection
|
||||||
|
{
|
||||||
|
return $this->filteredQuery($user, $search, $gender, $departmentId, $status, $enrollmentYear)
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get(['id', 'username', 'email', 'is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllForSelect(?string $status = null): Collection
|
||||||
|
{
|
||||||
|
return Student::join('departments', 'departments.id', '=', 'students.department_id')
|
||||||
|
->join('users', 'users.id', '=', 'students.user_id')
|
||||||
|
->join('user_profiles', 'user_profiles.user_id', '=', 'users.id')
|
||||||
|
->with([
|
||||||
|
'user:id,username',
|
||||||
|
'user.profile:id,user_id,full_name',
|
||||||
|
'department:id,name',
|
||||||
|
])
|
||||||
|
->when($status, fn ($q) => $q->where('students.status', $status))
|
||||||
|
->orderBy('departments.name')
|
||||||
|
->orderBy('students.current_semester')
|
||||||
|
->orderBy('user_profiles.full_name')
|
||||||
|
->get(['students.id', 'students.user_id', 'students.student_number', 'students.department_id', 'students.current_semester']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEnrollmentYears(): array
|
||||||
|
{
|
||||||
|
return Student::query()
|
||||||
|
->distinct()
|
||||||
|
->orderByDesc('enrollment_year')
|
||||||
|
->pluck('enrollment_year')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function filteredQuery(User $user, string $search, ?string $gender, ?int $departmentId, ?string $status, ?int $enrollmentYear = null): Builder
|
||||||
|
{
|
||||||
|
$taughtDepartmentIds = $user->hasRole(UserRole::Dosen->value)
|
||||||
|
? $user->lecturer?->departments()->pluck('departments.id')->all() ?? []
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return User::with([
|
||||||
|
'profile:id,user_id,full_name,phone_number,gender,birth_place,birth_date,address',
|
||||||
|
'student:id,user_id,student_number,department_id,enrollment_year,current_semester,status',
|
||||||
|
'student.department:id,name',
|
||||||
|
])
|
||||||
|
->whereHas('roles', fn ($q) => $q->where('name', UserRole::Mahasiswa->value))
|
||||||
|
->when($taughtDepartmentIds !== null, fn ($q) => $q->whereHas('student', fn ($q) => $q->whereIn('department_id', $taughtDepartmentIds)))
|
||||||
|
->when($search, fn ($q) => $q->where(function ($query) use ($search) {
|
||||||
|
$query->where('username', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))
|
||||||
|
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%"));
|
||||||
|
}))
|
||||||
|
->when($gender, fn ($q) => $q->whereHas('profile', fn ($q) => $q->where('gender', $gender)))
|
||||||
|
->when($departmentId, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('department_id', $departmentId)))
|
||||||
|
->when($status, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('status', $status)))
|
||||||
|
->when($enrollmentYear, fn ($q) => $q->whereHas('student', fn ($q) => $q->where('enrollment_year', $enrollmentYear)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ class PermissionCatalog
|
|||||||
'view-assignment-submissions', 'update-assignment-submissions',
|
'view-assignment-submissions', 'update-assignment-submissions',
|
||||||
'submit-assignments',
|
'submit-assignments',
|
||||||
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
|
'view-schedules', 'create-schedules', 'update-schedules', 'delete-schedules',
|
||||||
'view-attendances', 'create-attendances', 'delete-attendances', 'view-own-attendances',
|
'view-attendances', 'create-attendances', 'delete-attendances',
|
||||||
];
|
];
|
||||||
|
|
||||||
public const MANAGE = [
|
public const MANAGE = [
|
||||||
@ -50,7 +50,7 @@ class PermissionCatalog
|
|||||||
];
|
];
|
||||||
|
|
||||||
public const FEEDBACK = [
|
public const FEEDBACK = [
|
||||||
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status',
|
'view-feedback', 'create-feedback', 'update-feedback', 'delete-feedback', 'update-feedback-status', 'reply-feedback',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -12,8 +12,7 @@ public function up(): void
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->string('title', 200);
|
$table->string('title', 200);
|
||||||
$table->text('content');
|
$table->text('content');
|
||||||
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
|
$table->json('target_roles');
|
||||||
$table->integer('enrollment_year')->nullable();
|
|
||||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->softDeletes();
|
$table->softDeletes();
|
||||||
|
|||||||
@ -18,7 +18,7 @@ public function up(): void
|
|||||||
$table->text('message');
|
$table->text('message');
|
||||||
$table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value);
|
$table->enum('status', FeedbackStatus::values())->default(FeedbackStatus::Submitted->value);
|
||||||
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
|
$table->foreignId('handled_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->text('admin_notes')->nullable();
|
$table->text('reply')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,8 +13,7 @@ public function run(): void
|
|||||||
[
|
[
|
||||||
'title' => 'Libur Idul Fitri',
|
'title' => 'Libur Idul Fitri',
|
||||||
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
|
'content' => 'Perkuliahan diliburkan sesuai kalender akademik',
|
||||||
'department_id' => null,
|
'target_roles' => json_encode(['mahasiswa', 'dosen']),
|
||||||
'enrollment_year' => null,
|
|
||||||
'created_by' => 5,
|
'created_by' => 5,
|
||||||
'created_at' => '2026-03-01 08:00:00',
|
'created_at' => '2026-03-01 08:00:00',
|
||||||
'updated_at' => '2026-03-01 08:00:00',
|
'updated_at' => '2026-03-01 08:00:00',
|
||||||
@ -22,8 +21,7 @@ public function run(): void
|
|||||||
[
|
[
|
||||||
'title' => 'Jadwal UTS Prodi SI',
|
'title' => 'Jadwal UTS Prodi SI',
|
||||||
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
|
'content' => 'UTS Sistem Informasi dilaksanakan tanggal 20-25 Oktober',
|
||||||
'department_id' => 1,
|
'target_roles' => json_encode(['mahasiswa']),
|
||||||
'enrollment_year' => null,
|
|
||||||
'created_by' => 5,
|
'created_by' => 5,
|
||||||
'created_at' => '2026-10-01 08:00:00',
|
'created_at' => '2026-10-01 08:00:00',
|
||||||
'updated_at' => '2026-10-01 08:00:00',
|
'updated_at' => '2026-10-01 08:00:00',
|
||||||
|
|||||||
@ -40,6 +40,7 @@ public function run(): void
|
|||||||
'mahasiswa' => [
|
'mahasiswa' => [
|
||||||
'view-dashboard',
|
'view-dashboard',
|
||||||
'view-academic-terms',
|
'view-academic-terms',
|
||||||
|
'view-academic-advising-logs',
|
||||||
'view-courses',
|
'view-courses',
|
||||||
'view-course-registrations',
|
'view-course-registrations',
|
||||||
'view-letter-requests',
|
'view-letter-requests',
|
||||||
@ -50,7 +51,7 @@ public function run(): void
|
|||||||
'view-materials',
|
'view-materials',
|
||||||
'view-assignments',
|
'view-assignments',
|
||||||
'submit-assignments',
|
'submit-assignments',
|
||||||
'view-own-attendances',
|
'view-announcements',
|
||||||
...$feedbackSelfService,
|
...$feedbackSelfService,
|
||||||
],
|
],
|
||||||
'dosen' => [
|
'dosen' => [
|
||||||
@ -75,6 +76,11 @@ public function run(): void
|
|||||||
'view-attendances',
|
'view-attendances',
|
||||||
'create-attendances',
|
'create-attendances',
|
||||||
'delete-attendances',
|
'delete-attendances',
|
||||||
|
'view-letter-requests',
|
||||||
|
'create-letter-requests',
|
||||||
|
'update-letter-requests',
|
||||||
|
'delete-letter-requests',
|
||||||
|
'view-announcements',
|
||||||
...$feedbackSelfService,
|
...$feedbackSelfService,
|
||||||
],
|
],
|
||||||
'staff-admin' => [
|
'staff-admin' => [
|
||||||
@ -84,8 +90,9 @@ public function run(): void
|
|||||||
...$manage,
|
...$manage,
|
||||||
...array_diff($services, ['create-letter-requests', 'update-letter-requests']),
|
...array_diff($services, ['create-letter-requests', 'update-letter-requests']),
|
||||||
...$users,
|
...$users,
|
||||||
...$feedbackSelfService,
|
'view-feedback',
|
||||||
'update-feedback-status',
|
'update-feedback-status',
|
||||||
|
'reply-feedback',
|
||||||
],
|
],
|
||||||
'staff-keuangan' => [
|
'staff-keuangan' => [
|
||||||
'view-dashboard',
|
'view-dashboard',
|
||||||
|
|||||||
@ -36,10 +36,7 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments';
|
import { index as assignmentsRoute } from '@/routes/admin/academic-classes/assignments';
|
||||||
import {
|
import { index as attendancesRoute } from '@/routes/admin/academic-classes/attendances';
|
||||||
index as attendancesRoute,
|
|
||||||
mine as myAttendancesRoute,
|
|
||||||
} from '@/routes/admin/academic-classes/attendances';
|
|
||||||
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
|
import { index as materialsRoute } from '@/routes/admin/academic-classes/materials';
|
||||||
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
|
import { index as schedulesRoute } from '@/routes/admin/academic-classes/schedules';
|
||||||
import { index as logsRoute } from '@/routes/admin/developer/logs';
|
import { index as logsRoute } from '@/routes/admin/developer/logs';
|
||||||
@ -175,15 +172,6 @@ function buildNavMain({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(can('view-own-attendances')
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: 'Riwayat Kehadiran',
|
|
||||||
url: myAttendancesRoute.url(),
|
|
||||||
icon: ClipboardCheck,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const keuanganItems: NavItem[] = [
|
const keuanganItems: NavItem[] = [
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import { Paperclip } from 'lucide-react';
|
import { Eye, Paperclip } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||||
@ -19,12 +25,14 @@ type AttachmentPreviewDialogProps = {
|
|||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
variant?: 'link' | 'icon';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AttachmentPreviewDialog({
|
export function AttachmentPreviewDialog({
|
||||||
fileUrl,
|
fileUrl,
|
||||||
fileName,
|
fileName,
|
||||||
className,
|
className,
|
||||||
|
variant = 'link',
|
||||||
}: AttachmentPreviewDialogProps) {
|
}: AttachmentPreviewDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const extension = fileExtension(fileName);
|
const extension = fileExtension(fileName);
|
||||||
@ -34,17 +42,34 @@ export function AttachmentPreviewDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
{variant === 'icon' ? (
|
||||||
type="button"
|
<Tooltip>
|
||||||
onClick={() => setOpen(true)}
|
<TooltipTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
'inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80',
|
type="button"
|
||||||
className,
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
>
|
className={className}
|
||||||
<Paperclip className="h-3.5 w-3.5 shrink-0" />
|
onClick={() => setOpen(true)}
|
||||||
<span className="truncate">{fileName}</span>
|
>
|
||||||
</button>
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Lihat</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{fileName}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent
|
<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,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { InfiniteScroll } from '@inertiajs/react';
|
||||||
import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table';
|
import type { ColumnDef, ExpandedState, Row } from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@ -71,6 +72,15 @@ interface DataTableProps<TData, TValue> {
|
|||||||
onPerPageChange?: (perPage: number) => void;
|
onPerPageChange?: (perPage: number) => void;
|
||||||
onSearchChange?: (search: string) => void;
|
onSearchChange?: (search: string) => void;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
|
/**
|
||||||
|
* Enables infinite scroll instead of page-based pagination. `propName`
|
||||||
|
* must match the Inertia page prop returned via `Inertia::scroll()` on
|
||||||
|
* the backend so the frontend can track its merge/scroll metadata.
|
||||||
|
*/
|
||||||
|
infiniteScroll?: {
|
||||||
|
propName: string;
|
||||||
|
buffer?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const DragHandleContext = React.createContext<{
|
const DragHandleContext = React.createContext<{
|
||||||
@ -129,7 +139,7 @@ function SortableTableRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDebounce(callback: (value: string) => void, delay: number) {
|
export function useDebounce(callback: (value: string) => void, delay: number) {
|
||||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
return React.useCallback(
|
return React.useCallback(
|
||||||
@ -163,7 +173,10 @@ export function DataTable<TData, TValue>({
|
|||||||
onPerPageChange,
|
onPerPageChange,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchValue,
|
searchValue,
|
||||||
|
infiniteScroll,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const tbodyRef = React.useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
const [expanded, setExpanded] = React.useState<ExpandedState>(() => {
|
||||||
if (!defaultExpanded || !data.length) {
|
if (!defaultExpanded || !data.length) {
|
||||||
return {};
|
return {};
|
||||||
@ -183,7 +196,9 @@ export function DataTable<TData, TValue>({
|
|||||||
setLocalSearch(searchValue ?? '');
|
setLocalSearch(searchValue ?? '');
|
||||||
}, [searchValue]);
|
}, [searchValue]);
|
||||||
|
|
||||||
const isServerMode = !!pagination && !!onPageChange;
|
const isInfiniteScroll = !!infiniteScroll;
|
||||||
|
const hasServerPager = !isInfiniteScroll && !!pagination && !!onPageChange;
|
||||||
|
const hasServerSearch = !!onSearchChange;
|
||||||
const isSortable = !!onReorder && !!getRowId;
|
const isSortable = !!onReorder && !!getRowId;
|
||||||
|
|
||||||
const handleSearchDebounced = useDebounce(
|
const handleSearchDebounced = useDebounce(
|
||||||
@ -200,7 +215,7 @@ export function DataTable<TData, TValue>({
|
|||||||
header: () => <span className="block text-center">No</span>,
|
header: () => <span className="block text-center">No</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="block text-center">
|
<span className="block text-center">
|
||||||
{isServerMode
|
{hasServerPager
|
||||||
? (currentPage - 1) * (pagination?.per_page ?? 25) +
|
? (currentPage - 1) * (pagination?.per_page ?? 25) +
|
||||||
row.index +
|
row.index +
|
||||||
1
|
1
|
||||||
@ -275,14 +290,14 @@ export function DataTable<TData, TValue>({
|
|||||||
function handleSearchChange(value: string) {
|
function handleSearchChange(value: string) {
|
||||||
setLocalSearch(value);
|
setLocalSearch(value);
|
||||||
|
|
||||||
if (isServerMode) {
|
if (hasServerSearch) {
|
||||||
handleSearchDebounced(value);
|
handleSearchDebounced(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(searchKey || toolbar || isServerMode) && (
|
{(searchKey || toolbar || hasServerPager || isInfiniteScroll) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{searchKey && (
|
{searchKey && (
|
||||||
<Input
|
<Input
|
||||||
@ -296,7 +311,7 @@ export function DataTable<TData, TValue>({
|
|||||||
)}
|
)}
|
||||||
{toolbar}
|
{toolbar}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isServerMode && onPerPageChange && (
|
{hasServerPager && onPerPageChange && (
|
||||||
<Select
|
<Select
|
||||||
value={String(pagination?.per_page ?? 25)}
|
value={String(pagination?.per_page ?? 25)}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@ -349,7 +364,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody ref={tbodyRef}>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
isSortable ? (
|
isSortable ? (
|
||||||
<DndContext
|
<DndContext
|
||||||
@ -407,8 +422,7 @@ export function DataTable<TData, TValue>({
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
(() => {
|
(() => {
|
||||||
let previousGroup: string | null =
|
let previousGroup: string | null = null;
|
||||||
null;
|
|
||||||
|
|
||||||
return table
|
return table
|
||||||
.getRowModel()
|
.getRowModel()
|
||||||
@ -445,34 +459,32 @@ export function DataTable<TData, TValue>({
|
|||||||
>
|
>
|
||||||
{row
|
{row
|
||||||
.getVisibleCells()
|
.getVisibleCells()
|
||||||
.map(
|
.map((cell) => (
|
||||||
(cell) => (
|
<TableCell
|
||||||
<TableCell
|
key={
|
||||||
key={
|
cell.id
|
||||||
cell.id
|
}
|
||||||
}
|
className={
|
||||||
className={
|
(
|
||||||
(
|
|
||||||
cell
|
|
||||||
.column
|
|
||||||
.columnDef
|
|
||||||
.meta as {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
?.className
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
cell
|
cell
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.cell,
|
.meta as {
|
||||||
cell.getContext(),
|
className?: string;
|
||||||
)}
|
}
|
||||||
</TableCell>
|
)
|
||||||
),
|
?.className
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell
|
||||||
|
.column
|
||||||
|
.columnDef
|
||||||
|
.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{renderSubRow &&
|
{renderSubRow &&
|
||||||
row.getIsExpanded() && (
|
row.getIsExpanded() && (
|
||||||
@ -512,88 +524,124 @@ export function DataTable<TData, TValue>({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
{isInfiniteScroll ? (
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex flex-col items-center gap-1">
|
||||||
{isServerMode
|
<span className="text-sm text-muted-foreground">
|
||||||
? `Halaman ${currentPage} dari ${totalPages}`
|
Menampilkan {data.length.toLocaleString('id-ID')} dari{' '}
|
||||||
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
|
{(pagination?.total ?? data.length).toLocaleString(
|
||||||
</span>
|
'id-ID',
|
||||||
<div className="flex items-center gap-1">
|
)}{' '}
|
||||||
{isServerMode ? (
|
data
|
||||||
<>
|
</span>
|
||||||
<Button
|
<InfiniteScroll
|
||||||
variant="outline"
|
data={infiniteScroll.propName}
|
||||||
size="sm"
|
itemsElement={tbodyRef}
|
||||||
onClick={() => onPageChange(1)}
|
onlyNext
|
||||||
disabled={currentPage <= 1}
|
preserveUrl
|
||||||
>
|
buffer={infiniteScroll.buffer ?? 300}
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
next={({ loading, hasMore }) =>
|
||||||
</Button>
|
loading ? (
|
||||||
<Button
|
<span className="block py-2 text-center text-sm text-muted-foreground">
|
||||||
variant="outline"
|
Memuat data...
|
||||||
size="sm"
|
</span>
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
) : hasMore ? null : (
|
||||||
disabled={currentPage <= 1}
|
<span className="block py-2 text-center text-sm text-muted-foreground">
|
||||||
>
|
Semua data telah ditampilkan.
|
||||||
<ChevronLeft className="h-4 w-4" />
|
</span>
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.setPageIndex(0)}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
table.setPageIndex(table.getPageCount() - 1)
|
|
||||||
}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
<ChevronsRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{hasServerPager
|
||||||
|
? `Halaman ${currentPage} dari ${totalPages}`
|
||||||
|
: `Halaman ${table.getState().pagination.pageIndex + 1} dari ${table.getPageCount()}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasServerPager ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
table.setPageIndex(
|
||||||
|
table.getPageCount() - 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,58 @@ import {
|
|||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from "@/components/ui/input-group"
|
} from "@/components/ui/input-group"
|
||||||
|
|
||||||
const Combobox = ComboboxPrimitive.Root
|
function isGroupedItems(
|
||||||
|
items: readonly unknown[]
|
||||||
|
): items is ReadonlyArray<{ items: readonly unknown[] }> {
|
||||||
|
return (
|
||||||
|
items.length > 0 &&
|
||||||
|
typeof items[0] === "object" &&
|
||||||
|
items[0] !== null &&
|
||||||
|
"items" in items[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Combobox<Value, Multiple extends boolean | undefined = false>({
|
||||||
|
items,
|
||||||
|
multiple,
|
||||||
|
value,
|
||||||
|
isItemEqualToValue,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Root.Props<Value, Multiple>) {
|
||||||
|
// In multi-select mode, once an option is chosen it's represented as a
|
||||||
|
// chip, so it's removed from the dropdown list instead of staying there
|
||||||
|
// with a checkmark.
|
||||||
|
const filteredItems = React.useMemo(() => {
|
||||||
|
if (!multiple || !items) return items
|
||||||
|
|
||||||
|
const selected = value as unknown as Value[] | null | undefined
|
||||||
|
if (!Array.isArray(selected) || selected.length === 0) return items
|
||||||
|
|
||||||
|
const isEqual =
|
||||||
|
isItemEqualToValue ?? ((a: Value, b: Value) => Object.is(a, b))
|
||||||
|
const isNotSelected = (item: Value) =>
|
||||||
|
!selected.some((selectedValue) => isEqual(item, selectedValue))
|
||||||
|
|
||||||
|
if (isGroupedItems(items)) {
|
||||||
|
return items.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => isNotSelected(item as Value)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (items as readonly Value[]).filter(isNotSelected)
|
||||||
|
}, [items, multiple, value, isItemEqualToValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Root
|
||||||
|
items={filteredItems}
|
||||||
|
multiple={multiple}
|
||||||
|
value={value}
|
||||||
|
isItemEqualToValue={isItemEqualToValue}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||||
|
|||||||
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 { Head, router } from '@inertiajs/react';
|
||||||
import { format } from 'date-fns';
|
import { Plus } from 'lucide-react';
|
||||||
import {
|
|
||||||
Clock,
|
|
||||||
ClipboardList,
|
|
||||||
Paperclip,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Upload,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
import { DataCards } from '@/components/data-cards';
|
||||||
|
import type { PaginationState } from '@/components/data-table';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DateTimeField } from '@/components/datetime-field';
|
import { DateTimeField } from '@/components/datetime-field';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FileUploadField } from '@/components/file-upload-field';
|
import { FileUploadField } from '@/components/file-upload-field';
|
||||||
import type {
|
import type { FilterOptionGroup } from '@/components/filter-dialog';
|
||||||
FilterField,
|
|
||||||
FilterOptionGroup,
|
|
||||||
} from '@/components/filter-dialog';
|
|
||||||
import { FilterDialog } from '@/components/filter-dialog';
|
import { FilterDialog } from '@/components/filter-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RowActions } from '@/components/row-actions';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@/components/ui/accordion';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxCollection,
|
ComboboxCollection,
|
||||||
@ -53,6 +34,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { ViewMode } from '@/components/view-toggle';
|
||||||
|
import { ViewToggle } from '@/components/view-toggle';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
@ -62,10 +45,10 @@ import {
|
|||||||
submit,
|
submit,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/academic-classes/assignments';
|
} from '@/routes/admin/academic-classes/assignments';
|
||||||
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import type { Assignment } from '@/types/assignment';
|
import type { Assignment } from '@/types/assignment';
|
||||||
import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment';
|
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
|
||||||
|
import { createAssignmentCard } from './card';
|
||||||
|
import { createAssignmentColumns } from './columns';
|
||||||
|
|
||||||
type CourseClassOption = {
|
type CourseClassOption = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -80,12 +63,6 @@ type CourseClassOption = {
|
|||||||
|
|
||||||
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
||||||
|
|
||||||
type AcademicTermOption = {
|
|
||||||
id: number;
|
|
||||||
academic_year: string;
|
|
||||||
semester: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
assignments: {
|
assignments: {
|
||||||
data: Assignment[];
|
data: Assignment[];
|
||||||
@ -95,11 +72,9 @@ type Props = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
courseClasses: CourseClassOption[];
|
courseClasses: CourseClassOption[];
|
||||||
academicTerms: AcademicTermOption[];
|
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
filters: {
|
filters: {
|
||||||
course_class_id?: string;
|
course_class_id?: string;
|
||||||
academic_term_id?: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,29 +82,6 @@ function courseClassLabel(courseClass: CourseClassOption): string {
|
|||||||
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function percentageOf(part: number, total: number): number {
|
|
||||||
return total > 0 ? Math.round((part / total) * 100) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns red as the deadline passes, amber once it's within 3 days. */
|
|
||||||
function deadlineTextClass(deadline: string): string {
|
|
||||||
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
|
|
||||||
|
|
||||||
if (hoursLeft <= 0) {
|
|
||||||
return 'font-medium text-destructive';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoursLeft <= 24) {
|
|
||||||
return 'text-destructive';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoursLeft <= 72) {
|
|
||||||
return 'text-amber-600 dark:text-amber-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupCourseClassesByDepartment(
|
function groupCourseClassesByDepartment(
|
||||||
options: CourseClassOption[],
|
options: CourseClassOption[],
|
||||||
): CourseClassGroup[] {
|
): CourseClassGroup[] {
|
||||||
@ -209,7 +161,6 @@ function CourseClassField({
|
|||||||
export default function AssignmentIndex({
|
export default function AssignmentIndex({
|
||||||
assignments,
|
assignments,
|
||||||
courseClasses,
|
courseClasses,
|
||||||
academicTerms,
|
|
||||||
highlight,
|
highlight,
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -217,6 +168,7 @@ export default function AssignmentIndex({
|
|||||||
const [editing, setEditing] = useState<Assignment | null>(null);
|
const [editing, setEditing] = useState<Assignment | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Assignment | null>(null);
|
const [deleting, setDeleting] = useState<Assignment | null>(null);
|
||||||
const [submitting, setSubmitting] = useState<Assignment | null>(null);
|
const [submitting, setSubmitting] = useState<Assignment | null>(null);
|
||||||
|
const [view, setView] = useState<ViewMode>('table');
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canCreate = hasPermission('create-assignments');
|
const canCreate = hasPermission('create-assignments');
|
||||||
const canUpdate = hasPermission('update-assignments');
|
const canUpdate = hasPermission('update-assignments');
|
||||||
@ -224,15 +176,7 @@ export default function AssignmentIndex({
|
|||||||
const canViewSubmissions = hasPermission('view-assignment-submissions');
|
const canViewSubmissions = hasPermission('view-assignment-submissions');
|
||||||
const canSubmit = hasPermission('submit-assignments');
|
const canSubmit = hasPermission('submit-assignments');
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
const filterFields = [
|
||||||
{
|
|
||||||
key: 'academic_term_id',
|
|
||||||
label: 'Periode Akademik',
|
|
||||||
options: academicTerms.map((term) => ({
|
|
||||||
value: String(term.id),
|
|
||||||
label: formatAcademicTermLabel(term),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'course_class_id',
|
key: 'course_class_id',
|
||||||
label: 'Kelas',
|
label: 'Kelas',
|
||||||
@ -241,7 +185,7 @@ export default function AssignmentIndex({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const pagination = {
|
const pagination: PaginationState = {
|
||||||
current_page: assignments.current_page,
|
current_page: assignments.current_page,
|
||||||
last_page: assignments.last_page,
|
last_page: assignments.last_page,
|
||||||
per_page: assignments.per_page,
|
per_page: assignments.per_page,
|
||||||
@ -255,19 +199,6 @@ export default function AssignmentIndex({
|
|||||||
resetKeys: ['assignments'],
|
resetKeys: ['assignments'],
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleApplyFilters(newFilters: Record<string, string>) {
|
|
||||||
// Tanpa `academic_term_id` eksplisit, backend akan kembali ke
|
|
||||||
// periode aktif, jadi menghapus filter ini perlu dikirim eksplisit
|
|
||||||
// alih-alih hanya menghilangkan key-nya.
|
|
||||||
const clearedAcademicTerm =
|
|
||||||
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
|
|
||||||
|
|
||||||
applyFilters({
|
|
||||||
...newFilters,
|
|
||||||
...(clearedAcademicTerm ? { academic_term_id: '' } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
return;
|
||||||
@ -278,6 +209,37 @@ export default function AssignmentIndex({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns = createAssignmentColumns({
|
||||||
|
handleEdit: (assignment) => setEditing(assignment),
|
||||||
|
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||||
|
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAssignmentCard = createAssignmentCard({
|
||||||
|
handleEdit: (assignment) => setEditing(assignment),
|
||||||
|
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||||
|
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbar = (
|
||||||
|
<>
|
||||||
|
<FilterDialog
|
||||||
|
fields={filterFields}
|
||||||
|
activeFilters={filters}
|
||||||
|
onApply={applyFilters}
|
||||||
|
/>
|
||||||
|
<ViewToggle value={view} onChange={setView} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Tugas" />
|
<Head title="Tugas" />
|
||||||
@ -351,256 +313,29 @@ export default function AssignmentIndex({
|
|||||||
assignment={submitting}
|
assignment={submitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{view === 'table' ? (
|
||||||
<Input
|
<DataTable
|
||||||
placeholder="Cari judul tugas..."
|
columns={columns}
|
||||||
value={search}
|
data={assignments.data}
|
||||||
onChange={(event) =>
|
searchKey="title"
|
||||||
handleSearchChange(event.target.value)
|
pagination={pagination}
|
||||||
}
|
onSearchChange={handleSearchChange}
|
||||||
className="max-w-sm"
|
searchValue={search}
|
||||||
|
infiniteScroll={{ propName: 'assignments' }}
|
||||||
|
toolbar={toolbar}
|
||||||
/>
|
/>
|
||||||
<FilterDialog
|
|
||||||
fields={filterFields}
|
|
||||||
activeFilters={filters}
|
|
||||||
onApply={handleApplyFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignments.data.length === 0 ? (
|
|
||||||
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
|
||||||
Belum ada tugas.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<InfiniteScroll
|
<DataCards
|
||||||
data="assignments"
|
data={assignments.data}
|
||||||
as="div"
|
renderCard={renderAssignmentCard}
|
||||||
buffer={300}
|
getRowId={(assignment) => assignment.id}
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
searchKey="title"
|
||||||
loading={() => (
|
pagination={pagination}
|
||||||
<p className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
onSearchChange={handleSearchChange}
|
||||||
Memuat tugas...
|
searchValue={search}
|
||||||
</p>
|
infiniteScroll={{ propName: 'assignments' }}
|
||||||
)}
|
toolbar={toolbar}
|
||||||
>
|
/>
|
||||||
{assignments.data.map((assignment) => {
|
|
||||||
const mySubmission = assignment.submissions?.[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={assignment.id}
|
|
||||||
className={
|
|
||||||
highlight === assignment.id
|
|
||||||
? 'ring-2 ring-primary'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<CardTitle className="text-base leading-tight">
|
|
||||||
{assignment.title}
|
|
||||||
</CardTitle>
|
|
||||||
{assignment.course_class && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{assignment.course_class
|
|
||||||
.course?.code ??
|
|
||||||
''}{' '}
|
|
||||||
{assignment.course_class
|
|
||||||
.course?.name ?? ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<RowActions
|
|
||||||
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label:
|
|
||||||
mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'Kumpulkan Ulang'
|
|
||||||
: 'Kumpulkan Tugas',
|
|
||||||
icon: (
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show:
|
|
||||||
canSubmit &&
|
|
||||||
assignment.status ===
|
|
||||||
'open',
|
|
||||||
onClick: () =>
|
|
||||||
setSubmitting(
|
|
||||||
assignment,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pengumpulan',
|
|
||||||
icon: (
|
|
||||||
<ClipboardList className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show: canViewSubmissions,
|
|
||||||
href: submissionsIndex.url(
|
|
||||||
assignment.id,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
icon: (
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show: canUpdate,
|
|
||||||
onClick: () =>
|
|
||||||
setEditing(assignment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hapus',
|
|
||||||
icon: (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
),
|
|
||||||
show: canDelete,
|
|
||||||
onClick: () =>
|
|
||||||
setDeleting(assignment),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
assignment.status === 'open'
|
|
||||||
? 'secondary'
|
|
||||||
: 'destructive'
|
|
||||||
}
|
|
||||||
className="w-fit text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
AssignmentStatusLabels[
|
|
||||||
assignment.status
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</Badge>
|
|
||||||
{assignment.course_class
|
|
||||||
?.academic_term && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="w-fit text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{formatAcademicTermLabel(
|
|
||||||
assignment.course_class
|
|
||||||
.academic_term,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 ${deadlineTextClass(assignment.deadline)}`}
|
|
||||||
>
|
|
||||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{format(
|
|
||||||
new Date(assignment.deadline),
|
|
||||||
'd MMM yyyy, HH:mm',
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{assignment.attachment_url &&
|
|
||||||
assignment.attachment_name ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Paperclip className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<AttachmentPreviewDialog
|
|
||||||
fileUrl={
|
|
||||||
assignment.attachment_url
|
|
||||||
}
|
|
||||||
fileName={
|
|
||||||
assignment.attachment_name
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
|
||||||
{canSubmit ? (
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'Sudah Mengumpulkan'
|
|
||||||
: 'Belum Mengumpulkan'}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{assignment.submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
/{' '}
|
|
||||||
{(
|
|
||||||
assignment
|
|
||||||
.course_class
|
|
||||||
?.enrollments_count ??
|
|
||||||
0
|
|
||||||
).toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
Pengumpulan (
|
|
||||||
{percentageOf(
|
|
||||||
assignment.submissions_count,
|
|
||||||
assignment
|
|
||||||
.course_class
|
|
||||||
?.enrollments_count ??
|
|
||||||
0,
|
|
||||||
)}
|
|
||||||
%)
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{assignment.graded_submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
/{' '}
|
|
||||||
{assignment.submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
Dinilai (
|
|
||||||
{percentageOf(
|
|
||||||
assignment.graded_submissions_count,
|
|
||||||
assignment.submissions_count,
|
|
||||||
)}
|
|
||||||
%)
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignment.description && (
|
|
||||||
<Accordion
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
className="-mx-6 -mb-6 border-t"
|
|
||||||
>
|
|
||||||
<AccordionItem
|
|
||||||
value="description"
|
|
||||||
className="border-b-0"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
|
|
||||||
Deskripsi
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6">
|
|
||||||
<p className="text-sm whitespace-pre-line text-foreground">
|
|
||||||
{
|
|
||||||
assignment.description
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</InfiniteScroll>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
|
|||||||
@ -94,15 +94,17 @@ export default function SubmissionIndex({ assignment, submissions }: Props) {
|
|||||||
|
|
||||||
const columns: ColumnDef<Submission>[] = [
|
const columns: ColumnDef<Submission>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'student.student_number',
|
id: 'identity',
|
||||||
header: () => <span>NIM</span>,
|
header: () => <span>NIM</span>,
|
||||||
cell: ({ row }) => row.original.student?.student_number ?? '-',
|
cell: ({ row }) => (
|
||||||
},
|
<div className="flex flex-col">
|
||||||
{
|
<span>{row.original.student?.student_number ?? '-'}</span>
|
||||||
accessorKey: 'student.user.profile.full_name',
|
<span className="text-sm text-muted-foreground">
|
||||||
header: () => <span>Nama Mahasiswa</span>,
|
{row.original.student?.user?.profile?.full_name ??
|
||||||
cell: ({ row }) =>
|
'-'}
|
||||||
row.original.student?.user?.profile?.full_name ?? '-',
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
|
|||||||
@ -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 '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${courseClass.course?.code ?? ''} ${courseClass.course?.name ?? ''}`;
|
return `${courseClass.course?.code ?? ''} · ${courseClass.course?.name ?? ''}`;
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'file_name',
|
|
||||||
header: () => <span>File</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const material = row.original;
|
|
||||||
|
|
||||||
if (!material.file_url || !material.file_name) {
|
|
||||||
return <span className="text-muted-foreground">-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AttachmentPreviewDialog
|
|
||||||
fileUrl={material.file_url}
|
|
||||||
fileName={material.file_name}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (canUpdate || canDelete) {
|
columns.push({
|
||||||
columns.push({
|
id: 'actions',
|
||||||
id: 'actions',
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
header: () => <span className="block text-center">Aksi</span>,
|
meta: {
|
||||||
meta: {
|
className: 'w-[130px] text-center',
|
||||||
className: 'w-[100px] text-center',
|
headerClassName: 'w-[130px] text-center',
|
||||||
headerClassName: 'w-[100px] text-center',
|
},
|
||||||
},
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const material = row.original;
|
||||||
const material = row.original;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{material.file_url && material.file_name && (
|
||||||
|
<AttachmentPreviewDialog
|
||||||
|
fileUrl={material.file_url}
|
||||||
|
fileName={material.file_name}
|
||||||
|
variant="icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RowActions
|
<RowActions
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@ -114,10 +103,10 @@ export function createMaterialColumns(
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
});
|
},
|
||||||
}
|
});
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user