Compare commits
2 Commits
5cc33cce2c
...
e9acf29c20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9acf29c20 | ||
|
|
8d56ed16ac |
@ -6,10 +6,12 @@
|
|||||||
use App\Http\Requests\Admin\Services\AcademicAdvisingLogRequest;
|
use App\Http\Requests\Admin\Services\AcademicAdvisingLogRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\AcademicAdvisingLog;
|
use App\Models\AcademicAdvisingLog;
|
||||||
|
use App\Models\Lecturer;
|
||||||
use App\Services\Admin\Services\AcademicAdvisingLogService;
|
use App\Services\Admin\Services\AcademicAdvisingLogService;
|
||||||
use App\Services\Admin\Users\LecturerService;
|
use App\Services\Admin\Users\LecturerService;
|
||||||
use App\Services\Admin\Users\StudentService;
|
use App\Services\Admin\Users\StudentService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@ -34,8 +36,36 @@ public function index(PaginatedRequest $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mine(Request $request): Response
|
||||||
|
{
|
||||||
|
$lecturer = $this->currentLecturer($request);
|
||||||
|
|
||||||
|
return Inertia::render('lecturer/academic-advising-logs/index', [
|
||||||
|
'logs' => $this->service->forLecturer($lecturer),
|
||||||
|
'advisees' => $lecturer->advisees()
|
||||||
|
->with('user.profile')
|
||||||
|
->get(['id', 'user_id', 'student_number']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(AcademicAdvisingLogRequest $request): RedirectResponse
|
public function store(AcademicAdvisingLogRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
if ($request->routeIs('lecturer.*')) {
|
||||||
|
$lecturer = $this->currentLecturer($request);
|
||||||
|
|
||||||
|
$this->service->create([
|
||||||
|
'student_id' => $request->validated('student_id'),
|
||||||
|
'lecturer_id' => $lecturer->id,
|
||||||
|
'topic' => $request->validated('topic'),
|
||||||
|
'notes' => $request->validated('notes'),
|
||||||
|
'session_date' => $request->validated('session_date'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil dicatat.']);
|
||||||
|
|
||||||
|
return to_route('lecturer.academic-advising-logs.index');
|
||||||
|
}
|
||||||
|
|
||||||
$this->service->create($request->validated());
|
$this->service->create($request->validated());
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil ditambahkan.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil ditambahkan.']);
|
||||||
@ -58,4 +88,13 @@ public function destroy(AcademicAdvisingLog $academicAdvisingLog): RedirectRespo
|
|||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Bimbingan akademik berhasil dihapus.'])->back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentLecturer(Request $request): Lecturer
|
||||||
|
{
|
||||||
|
$lecturer = $request->user()->lecturer;
|
||||||
|
|
||||||
|
abort_if(! $lecturer, 403);
|
||||||
|
|
||||||
|
return $lecturer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,11 @@
|
|||||||
use App\Http\Requests\Admin\Services\LetterRequestRequest;
|
use App\Http\Requests\Admin\Services\LetterRequestRequest;
|
||||||
use App\Http\Requests\PaginatedRequest;
|
use App\Http\Requests\PaginatedRequest;
|
||||||
use App\Models\LetterRequest;
|
use App\Models\LetterRequest;
|
||||||
|
use App\Models\Student;
|
||||||
use App\Services\Admin\Services\LetterRequestService;
|
use App\Services\Admin\Services\LetterRequestService;
|
||||||
use App\Services\Admin\Users\StudentService;
|
use App\Services\Admin\Users\StudentService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@ -31,8 +33,45 @@ public function index(PaginatedRequest $request): Response
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mine(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('student/letter-requests/index', [
|
||||||
|
'letterRequests' => $this->service->mine($this->currentStudent($request)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('student/letter-requests/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, LetterRequest $letterRequest): Response
|
||||||
|
{
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
|
||||||
|
abort_if($letterRequest->student_id !== $student->id, 403);
|
||||||
|
|
||||||
|
return Inertia::render('student/letter-requests/show', [
|
||||||
|
'letterRequest' => $this->service->withDetails($letterRequest),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function store(LetterRequestRequest $request): RedirectResponse
|
public function store(LetterRequestRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
if ($request->routeIs('student.*')) {
|
||||||
|
$student = $this->currentStudent($request);
|
||||||
|
|
||||||
|
$letterRequest = $this->service->create([
|
||||||
|
'student_id' => $student->id,
|
||||||
|
'letter_type' => $request->validated('letter_type'),
|
||||||
|
'purpose' => $request->validated('purpose'),
|
||||||
|
], null);
|
||||||
|
|
||||||
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil diajukan.']);
|
||||||
|
|
||||||
|
return to_route('student.letter-requests.show', $letterRequest);
|
||||||
|
}
|
||||||
|
|
||||||
$this->service->create($request->validated(), $request->file('result'));
|
$this->service->create($request->validated(), $request->file('result'));
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil ditambahkan.']);
|
Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil ditambahkan.']);
|
||||||
@ -55,4 +94,13 @@ public function destroy(LetterRequest $letterRequest): RedirectResponse
|
|||||||
|
|
||||||
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil dihapus.'])->back();
|
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Surat permohonan berhasil dihapus.'])->back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function currentStudent(Request $request): Student
|
||||||
|
{
|
||||||
|
$student = $request->user()->student;
|
||||||
|
|
||||||
|
abort_if(! $student, 403);
|
||||||
|
|
||||||
|
return $student;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,27 @@ class AcademicAdvisingLogRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->routeIs('lecturer.*')) {
|
||||||
|
return (bool) $this->user()->lecturer;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
if ($this->routeIs('lecturer.*')) {
|
||||||
|
$lecturer = $this->user()->lecturer;
|
||||||
|
$adviseeIds = $lecturer ? $lecturer->advisees()->pluck('id') : collect();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'student_id' => ['required', 'integer', Rule::in($adviseeIds)],
|
||||||
|
'topic' => ['nullable', 'string', 'max:150'],
|
||||||
|
'notes' => ['nullable', 'string'],
|
||||||
|
'session_date' => ['nullable', 'date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||||
'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')],
|
'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')],
|
||||||
@ -22,4 +38,11 @@ public function rules(): array
|
|||||||
'session_date' => ['nullable', 'date'],
|
'session_date' => ['nullable', 'date'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'student_id.in' => 'Mahasiswa yang dipilih bukan mahasiswa bimbingan Anda.',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,22 @@ class LetterRequestRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
|
if ($this->routeIs('student.*')) {
|
||||||
|
return (bool) $this->user()->student;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
if ($this->routeIs('student.*')) {
|
||||||
|
return [
|
||||||
|
'letter_type' => ['required', 'string', 'max:100'],
|
||||||
|
'purpose' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||||
'letter_type' => ['required', 'string', 'max:100'],
|
'letter_type' => ['required', 'string', 'max:100'],
|
||||||
|
|||||||
@ -23,4 +23,9 @@ public function user(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,16 @@
|
|||||||
namespace App\Services\Admin\Manage;
|
namespace App\Services\Admin\Manage;
|
||||||
|
|
||||||
use App\Models\Announcement;
|
use App\Models\Announcement;
|
||||||
|
use App\Models\Student;
|
||||||
|
use App\Services\NotificationService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class AnnouncementService
|
class AnnouncementService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NotificationService $notificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Announcement::query()
|
return Announcement::query()
|
||||||
@ -20,13 +26,27 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
|||||||
|
|
||||||
public function create(array $data): Announcement
|
public function create(array $data): Announcement
|
||||||
{
|
{
|
||||||
return Announcement::create([
|
$announcement = Announcement::create([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'content' => $data['content'],
|
'content' => $data['content'],
|
||||||
'department_id' => $data['department_id'] ?? null,
|
'department_id' => $data['department_id'] ?? null,
|
||||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
'enrollment_year' => $data['enrollment_year'] ?? null,
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$recipientUserIds = Student::query()
|
||||||
|
->when($announcement->department_id, fn ($q, $departmentId) => $q->where('department_id', $departmentId))
|
||||||
|
->when($announcement->enrollment_year, fn ($q, $year) => $q->where('enrollment_year', $year))
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
$this->notificationService->sendToUsers(
|
||||||
|
$recipientUserIds,
|
||||||
|
$announcement->title,
|
||||||
|
$announcement->content,
|
||||||
|
$announcement->created_by,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $announcement;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Announcement $announcement, array $data): Announcement
|
public function update(Announcement $announcement, array $data): Announcement
|
||||||
|
|||||||
@ -3,10 +3,24 @@
|
|||||||
namespace App\Services\Admin\Services;
|
namespace App\Services\Admin\Services;
|
||||||
|
|
||||||
use App\Models\AcademicAdvisingLog;
|
use App\Models\AcademicAdvisingLog;
|
||||||
|
use App\Models\Lecturer;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class AcademicAdvisingLogService
|
class AcademicAdvisingLogService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, AcademicAdvisingLog>
|
||||||
|
*/
|
||||||
|
public function forLecturer(Lecturer $lecturer): Collection
|
||||||
|
{
|
||||||
|
return AcademicAdvisingLog::query()
|
||||||
|
->where('lecturer_id', $lecturer->id)
|
||||||
|
->with('student.user.profile')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?int $lecturerId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return AcademicAdvisingLog::query()
|
return AcademicAdvisingLog::query()
|
||||||
|
|||||||
@ -4,11 +4,30 @@
|
|||||||
|
|
||||||
use App\Enums\LetterStatus;
|
use App\Enums\LetterStatus;
|
||||||
use App\Models\LetterRequest;
|
use App\Models\LetterRequest;
|
||||||
|
use App\Models\Student;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class LetterRequestService
|
class LetterRequestService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, LetterRequest>
|
||||||
|
*/
|
||||||
|
public function mine(Student $student): Collection
|
||||||
|
{
|
||||||
|
return LetterRequest::query()
|
||||||
|
->where('student_id', $student->id)
|
||||||
|
->with('processor.profile')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withDetails(LetterRequest $letterRequest): LetterRequest
|
||||||
|
{
|
||||||
|
return $letterRequest->load('processor.profile');
|
||||||
|
}
|
||||||
|
|
||||||
public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
public function paginated(int $perPage = 25, string $search = '', ?string $status = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return LetterRequest::query()
|
return LetterRequest::query()
|
||||||
|
|||||||
@ -5,13 +5,40 @@
|
|||||||
use App\Models\Notification;
|
use App\Models\Notification;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class NotificationService
|
class NotificationService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, int>|array<int, int> $userIds
|
||||||
|
*/
|
||||||
|
public function sendToUsers(Collection|array $userIds, string $title, ?string $content = null, ?int $createdBy = null): void
|
||||||
|
{
|
||||||
|
$userIds = collect($userIds)->filter()->unique()->values();
|
||||||
|
|
||||||
|
if ($userIds->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdBy ??= auth()->id();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
Notification::insert($userIds->map(fn (int $userId) => [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'created_by' => $createdBy,
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
'is_read' => false,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
])->all());
|
||||||
|
}
|
||||||
|
|
||||||
public function paginated(User $user, int $perPage = 15): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 15): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Notification::query()
|
return Notification::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
|
->with('creator.profile')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public function up(): void
|
|||||||
Schema::create('notifications', function (Blueprint $table) {
|
Schema::create('notifications', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
$table->string('title', 150)->nullable();
|
$table->string('title', 150)->nullable();
|
||||||
$table->text('content')->nullable();
|
$table->text('content')->nullable();
|
||||||
$table->boolean('is_read')->nullable()->default(false);
|
$table->boolean('is_read')->nullable()->default(false);
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->foreignId('created_by')->nullable()->after('user_id')->constrained('users')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('notifications', function (Blueprint $table) {
|
||||||
|
$table->dropConstrainedForeignId('created_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -50,12 +50,21 @@ import { index as letterRequestsRoute } from '@/routes/admin/services/letter-req
|
|||||||
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
|
import { index as administratorsRoute } from '@/routes/admin/users/administrators';
|
||||||
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
import { index as lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||||
import { index as studentsRoute } from '@/routes/admin/users/students';
|
import { index as studentsRoute } from '@/routes/admin/users/students';
|
||||||
|
import { index as lecturerAcademicAdvisingLogsRoute } from '@/routes/lecturer/academic-advising-logs';
|
||||||
|
import { index as studentCourseRegistrationsRoute } from '@/routes/student/course-registrations';
|
||||||
|
import { index as studentLetterRequestsRoute } from '@/routes/student/letter-requests';
|
||||||
|
import type { Auth } from '@/types/auth';
|
||||||
|
|
||||||
const data: {
|
const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
|
||||||
navMain: (NavGroup | NavItem)[];
|
|
||||||
navSecondary: { title: string; url: string; icon: Icon }[];
|
function buildNavMain({
|
||||||
} = {
|
isMahasiswa,
|
||||||
navMain: [
|
isDosen,
|
||||||
|
}: {
|
||||||
|
isMahasiswa: boolean;
|
||||||
|
isDosen: boolean;
|
||||||
|
}): (NavGroup | NavItem)[] {
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
name: 'Dasbor',
|
name: 'Dasbor',
|
||||||
url: '#',
|
url: '#',
|
||||||
@ -106,7 +115,9 @@ const data: {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'Registrasi KRS',
|
name: 'Registrasi KRS',
|
||||||
url: courseRegistrationsRoute.url(),
|
url: isMahasiswa
|
||||||
|
? studentCourseRegistrationsRoute.url()
|
||||||
|
: courseRegistrationsRoute.url(),
|
||||||
icon: FileCheck2,
|
icon: FileCheck2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -161,33 +172,43 @@ const data: {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'Surat Permohonan',
|
name: 'Surat Permohonan',
|
||||||
url: letterRequestsRoute.url(),
|
url: isMahasiswa
|
||||||
|
? studentLetterRequestsRoute.url()
|
||||||
|
: letterRequestsRoute.url(),
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Bimbingan Akademik',
|
name: 'Bimbingan Akademik',
|
||||||
url: academicAdvisingLogsRoute.url(),
|
url: isDosen
|
||||||
|
? lecturerAcademicAdvisingLogsRoute.url()
|
||||||
|
: academicAdvisingLogsRoute.url(),
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
navSecondary: [
|
}
|
||||||
{
|
|
||||||
title: 'Kritik dan Saran',
|
const navSecondary: { title: string; url: string; icon: Icon }[] = [
|
||||||
url: feedbackRoute.url(),
|
{
|
||||||
icon: IconMessageDots,
|
title: 'Kritik dan Saran',
|
||||||
},
|
url: feedbackRoute.url(),
|
||||||
{
|
icon: IconMessageDots,
|
||||||
title: 'Bantuan',
|
},
|
||||||
url: '#',
|
{
|
||||||
icon: IconHelp,
|
title: 'Bantuan',
|
||||||
},
|
url: '#',
|
||||||
],
|
icon: IconHelp,
|
||||||
};
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { name } = usePage().props;
|
const { name, auth } = usePage<{ auth: Auth }>().props;
|
||||||
|
const roleNames = auth?.user?.roles?.map((role) => role.name) ?? [];
|
||||||
|
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
|
||||||
|
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
|
||||||
|
const isDosen = !isStaff && roleNames.includes('dosen');
|
||||||
|
const navMain = buildNavMain({ isMahasiswa, isDosen });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
@ -209,8 +230,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={navMain} />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import { useState } from 'react';
|
|||||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
|
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
|
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
|
||||||
import type { Notification, NotificationPage } from '@/types/notification';
|
import type { Notification, NotificationPage } from '@/types/notification';
|
||||||
@ -23,6 +30,7 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<Notification | null>(null);
|
||||||
|
|
||||||
const hasMore = notifications.current_page < notifications.last_page;
|
const hasMore = notifications.current_page < notifications.last_page;
|
||||||
const hasNotifications = notifications.data.length > 0;
|
const hasNotifications = notifications.data.length > 0;
|
||||||
@ -47,6 +55,14 @@ export function NotificationBell() {
|
|||||||
router.delete(destroy(notification.id).url, { preserveScroll: true });
|
router.delete(destroy(notification.id).url, { preserveScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenDetail(notification: Notification) {
|
||||||
|
setDetail(notification);
|
||||||
|
|
||||||
|
if (!notification.is_read) {
|
||||||
|
handleMarkRead(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Combobox open={open} onOpenChange={setOpen}>
|
<Combobox open={open} onOpenChange={setOpen}>
|
||||||
@ -70,9 +86,9 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
<ComboboxContent
|
<ComboboxContent
|
||||||
align="end"
|
align="end"
|
||||||
className="w-96 max-w-[90vw] min-w-96 p-0"
|
className="flex w-96 max-w-[90vw] min-w-96 flex-col p-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b p-3">
|
<div className="flex shrink-0 items-center justify-between border-b p-3">
|
||||||
<span className="font-medium">Notifikasi</span>
|
<span className="font-medium">Notifikasi</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@ -96,7 +112,7 @@ export function NotificationBell() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{hasNotifications ? (
|
{hasNotifications ? (
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
{notifications.data.map((notification) => (
|
{notifications.data.map((notification) => (
|
||||||
@ -108,7 +124,13 @@ export function NotificationBell() {
|
|||||||
'bg-primary/5',
|
'bg-primary/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 cursor-pointer items-start gap-2 text-left"
|
||||||
|
onClick={() =>
|
||||||
|
handleOpenDetail(notification)
|
||||||
|
}
|
||||||
|
>
|
||||||
{!notification.is_read && (
|
{!notification.is_read && (
|
||||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
@ -117,11 +139,14 @@ export function NotificationBell() {
|
|||||||
{notification.title ?? '-'}
|
{notification.title ?? '-'}
|
||||||
</p>
|
</p>
|
||||||
{notification.content && (
|
{notification.content && (
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||||
{notification.content}
|
{notification.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{notification.creator
|
||||||
|
?.profile?.full_name &&
|
||||||
|
`${notification.creator.profile.full_name} · `}
|
||||||
{format(
|
{format(
|
||||||
new Date(
|
new Date(
|
||||||
notification.created_at,
|
notification.created_at,
|
||||||
@ -130,7 +155,7 @@ export function NotificationBell() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{!notification.is_read && (
|
{!notification.is_read && (
|
||||||
<Button
|
<Button
|
||||||
@ -202,6 +227,35 @@ export function NotificationBell() {
|
|||||||
confirmLabel="Hapus"
|
confirmLabel="Hapus"
|
||||||
onConfirm={handleClearAll}
|
onConfirm={handleClearAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={detail !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{detail?.title ?? '-'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{detail?.creator?.profile?.full_name &&
|
||||||
|
`${detail.creator.profile.full_name} · `}
|
||||||
|
{detail &&
|
||||||
|
format(
|
||||||
|
new Date(detail.created_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{detail?.content && (
|
||||||
|
<p className="overflow-y-auto text-sm whitespace-pre-wrap text-foreground">
|
||||||
|
{detail.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import type { AcademicAdvisingLog } from '@/types/academic-advising-log';
|
||||||
|
|
||||||
|
export const advisingLogColumns: ColumnDef<AcademicAdvisingLog>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'student.student_number',
|
||||||
|
header: () => <span>Mahasiswa</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const student = row.original.student;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{student?.user?.profile?.full_name ?? 'N/A'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{student?.student_number}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'topic',
|
||||||
|
header: () => <span>Topik</span>,
|
||||||
|
cell: ({ row }) => row.original.topic || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'notes',
|
||||||
|
header: () => <span>Catatan</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<p className="line-clamp-2 max-w-sm text-muted-foreground">
|
||||||
|
{row.original.notes || '-'}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'session_date',
|
||||||
|
header: () => <span>Tanggal Sesi</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const sessionDate = row.original.session_date;
|
||||||
|
|
||||||
|
return sessionDate
|
||||||
|
? format(new Date(sessionDate), 'd MMM yyyy, HH:mm')
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
156
resources/js/pages/lecturer/academic-advising-logs/index.tsx
Normal file
156
resources/js/pages/lecturer/academic-advising-logs/index.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { DateTimeField } from '@/components/datetime-field';
|
||||||
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { store } from '@/routes/lecturer/academic-advising-logs';
|
||||||
|
import type { AcademicAdvisingLog } from '@/types/academic-advising-log';
|
||||||
|
import { advisingLogColumns } from './columns';
|
||||||
|
|
||||||
|
type Advisee = {
|
||||||
|
id: number;
|
||||||
|
student_number: string;
|
||||||
|
user: { profile: { full_name: string } | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
logs: AcademicAdvisingLog[];
|
||||||
|
advisees: Advisee[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function adviseeLabel(advisee: Advisee): string {
|
||||||
|
return `${advisee.user?.profile?.full_name ?? 'N/A'} - ${advisee.student_number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LecturerAcademicAdvisingLogIndex({
|
||||||
|
logs,
|
||||||
|
advisees,
|
||||||
|
}: Props) {
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Bimbingan Akademik" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Bimbingan Akademik"
|
||||||
|
actions={
|
||||||
|
<Button asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Tambah Bimbingan
|
||||||
|
</button>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateForm
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
advisees={advisees}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={advisingLogColumns}
|
||||||
|
data={logs}
|
||||||
|
emptyText="Belum ada log bimbingan akademik."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
advisees,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
advisees: Advisee[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Tambah Bimbingan Akademik"
|
||||||
|
action={store()}
|
||||||
|
resetOnSuccess
|
||||||
|
onSuccess={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{({ errors }) => (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>
|
||||||
|
Mahasiswa{' '}
|
||||||
|
<span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select name="student_id">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih mahasiswa bimbingan" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{advisees.map((advisee) => (
|
||||||
|
<SelectItem
|
||||||
|
key={advisee.id}
|
||||||
|
value={String(advisee.id)}
|
||||||
|
>
|
||||||
|
{adviseeLabel(advisee)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{advisees.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Anda belum memiliki mahasiswa bimbingan.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<InputError message={errors.student_id} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="topic">Topik</Label>
|
||||||
|
<Input
|
||||||
|
id="topic"
|
||||||
|
name="topic"
|
||||||
|
placeholder="Masukkan topik bimbingan"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.topic} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notes">Catatan</Label>
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
name="notes"
|
||||||
|
placeholder="Masukkan catatan bimbingan"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.notes} />
|
||||||
|
</div>
|
||||||
|
<DateTimeField
|
||||||
|
label="Tanggal Sesi"
|
||||||
|
name="session_date"
|
||||||
|
placeholder="Pilih tanggal sesi"
|
||||||
|
error={errors.session_date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
resources/js/pages/student/letter-requests/columns.tsx
Normal file
91
resources/js/pages/student/letter-requests/columns.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { show } from '@/routes/student/letter-requests';
|
||||||
|
import type { LetterRequest, LetterStatus } from '@/types/letter-request';
|
||||||
|
import { LetterStatusLabels } from '@/types/letter-request';
|
||||||
|
|
||||||
|
export const letterRequestColumns: ColumnDef<LetterRequest>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'letter_type',
|
||||||
|
header: () => <span>Jenis Surat</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const letterRequest = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{letterRequest.letter_type}</p>
|
||||||
|
{letterRequest.purpose && (
|
||||||
|
<p className="line-clamp-1 max-w-xs text-xs text-muted-foreground">
|
||||||
|
{letterRequest.purpose}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: () => <span className="block text-center">Status</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'w-[130px] text-center',
|
||||||
|
headerClassName: 'w-[130px] text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.original.status as LetterStatus | null;
|
||||||
|
|
||||||
|
const variant =
|
||||||
|
status === 'completed'
|
||||||
|
? 'default'
|
||||||
|
: status === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: status === 'in_process'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{status ? (
|
||||||
|
<Badge variant={variant}>
|
||||||
|
{LetterStatusLabels[status]}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'submitted_at',
|
||||||
|
header: () => <span>Diajukan</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const submittedAt = row.original.submitted_at;
|
||||||
|
|
||||||
|
return submittedAt
|
||||||
|
? format(new Date(submittedAt), 'd MMM yyyy, HH:mm')
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'w-[80px] text-center',
|
||||||
|
headerClassName: 'w-[80px] text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<RowActions
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Lihat Detail',
|
||||||
|
icon: <Eye className="h-4 w-4" />,
|
||||||
|
href: show.url(row.original.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
79
resources/js/pages/student/letter-requests/create.tsx
Normal file
79
resources/js/pages/student/letter-requests/create.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Form, Head, Link } from '@inertiajs/react';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import InputError from '@/components/input-error';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { index, store } from '@/routes/student/letter-requests';
|
||||||
|
|
||||||
|
export default function LetterRequestCreate() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Ajukan Surat Permohonan" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Ajukan Surat Permohonan"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form action={store()}>
|
||||||
|
{({ errors, processing }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Detail Permohonan</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="letter_type">
|
||||||
|
Jenis Surat{' '}
|
||||||
|
<span className="text-destructive">
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="letter_type"
|
||||||
|
name="letter_type"
|
||||||
|
placeholder="Contoh: Surat Aktif Kuliah"
|
||||||
|
aria-invalid={!!errors.letter_type}
|
||||||
|
/>
|
||||||
|
<InputError message={errors.letter_type} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="purpose">Keperluan</Label>
|
||||||
|
<Textarea
|
||||||
|
id="purpose"
|
||||||
|
name="purpose"
|
||||||
|
placeholder="Jelaskan keperluan surat ini"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.purpose} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end">
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
Ajukan
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
resources/js/pages/student/letter-requests/index.tsx
Normal file
40
resources/js/pages/student/letter-requests/index.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { create } from '@/routes/student/letter-requests';
|
||||||
|
import type { LetterRequest } from '@/types/letter-request';
|
||||||
|
import { letterRequestColumns } from './columns';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
letterRequests: LetterRequest[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LetterRequestIndex({ letterRequests }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Surat Permohonan" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Surat Permohonan"
|
||||||
|
actions={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={create.url()}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Ajukan Surat
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={letterRequestColumns}
|
||||||
|
data={letterRequests}
|
||||||
|
emptyText="Anda belum pernah mengajukan surat permohonan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
resources/js/pages/student/letter-requests/show.tsx
Normal file
123
resources/js/pages/student/letter-requests/show.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { Head, Link } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ArrowLeft, Paperclip } from 'lucide-react';
|
||||||
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { index } from '@/routes/student/letter-requests';
|
||||||
|
import type { LetterRequest, LetterStatus } from '@/types/letter-request';
|
||||||
|
import { LetterStatusLabels } from '@/types/letter-request';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
letterRequest: LetterRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LetterRequestShow({ letterRequest }: Props) {
|
||||||
|
const status = letterRequest.status as LetterStatus | null;
|
||||||
|
|
||||||
|
const variant =
|
||||||
|
status === 'completed'
|
||||||
|
? 'default'
|
||||||
|
: status === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: status === 'in_process'
|
||||||
|
? 'secondary'
|
||||||
|
: 'outline';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Detail Surat Permohonan" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Detail Surat Permohonan"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={index.url()}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Kembali
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{letterRequest.letter_type}</CardTitle>
|
||||||
|
{letterRequest.submitted_at && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Diajukan pada{' '}
|
||||||
|
{format(
|
||||||
|
new Date(letterRequest.submitted_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<Badge variant={variant}>
|
||||||
|
{LetterStatusLabels[status]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 border-t pt-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Keperluan
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{letterRequest.purpose || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{letterRequest.processor && (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Diproses Oleh
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{letterRequest.processor.profile
|
||||||
|
?.full_name ?? '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{letterRequest.completed_at && (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Selesai Pada
|
||||||
|
</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
{format(
|
||||||
|
new Date(letterRequest.completed_at),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-muted-foreground">
|
||||||
|
Dokumen
|
||||||
|
</p>
|
||||||
|
{letterRequest.result_url ? (
|
||||||
|
<a
|
||||||
|
href={letterRequest.result_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-primary underline underline-offset-4 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
|
{letterRequest.result_name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Belum tersedia.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ export type Notification = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
|
creator: { profile: { full_name: string } | null } | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -85,11 +85,25 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('role:mahasiswa')->prefix('course-registrations')->name('student.course-registrations.')->group(function () {
|
Route::middleware('role:mahasiswa')->group(function () {
|
||||||
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
|
Route::prefix('course-registrations')->name('student.course-registrations.')->group(function () {
|
||||||
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
|
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
|
||||||
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
|
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
|
||||||
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
|
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
|
||||||
|
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::prefix('letter-requests')->name('student.letter-requests.')->group(function () {
|
||||||
|
Route::get('/', [LetterRequestController::class, 'mine'])->name('index');
|
||||||
|
Route::get('create', [LetterRequestController::class, 'create'])->name('create');
|
||||||
|
Route::post('/', [LetterRequestController::class, 'store'])->name('store');
|
||||||
|
Route::get('{letter_request}', [LetterRequestController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware('role:dosen')->prefix('academic-advising-logs')->name('lecturer.academic-advising-logs.')->group(function () {
|
||||||
|
Route::get('/', [AcademicAdvisingLogController::class, 'mine'])->name('index');
|
||||||
|
Route::post('/', [AcademicAdvisingLogController::class, 'store'])->name('store');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::prefix('admin/finances')->name('admin.finances.')->group(function () {
|
Route::prefix('admin/finances')->name('admin.finances.')->group(function () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user