feat: implement academic advising and letter request management for lecturers and students
This commit is contained in:
parent
8d56ed16ac
commit
e9acf29c20
@ -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'],
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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