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\PaginatedRequest;
|
||||
use App\Models\AcademicAdvisingLog;
|
||||
use App\Models\Lecturer;
|
||||
use App\Services\Admin\Services\AcademicAdvisingLogService;
|
||||
use App\Services\Admin\Users\LecturerService;
|
||||
use App\Services\Admin\Users\StudentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
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
|
||||
{
|
||||
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());
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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\PaginatedRequest;
|
||||
use App\Models\LetterRequest;
|
||||
use App\Models\Student;
|
||||
use App\Services\Admin\Services\LetterRequestService;
|
||||
use App\Services\Admin\Users\StudentService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
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
|
||||
{
|
||||
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'));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if ($this->routeIs('lecturer.*')) {
|
||||
return (bool) $this->user()->lecturer;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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 [
|
||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||
'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')],
|
||||
@ -22,4 +38,11 @@ public function rules(): array
|
||||
'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
|
||||
{
|
||||
if ($this->routeIs('student.*')) {
|
||||
return (bool) $this->user()->student;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->routeIs('student.*')) {
|
||||
return [
|
||||
'letter_type' => ['required', 'string', 'max:100'],
|
||||
'purpose' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'student_id' => ['required', 'integer', Rule::exists('students', 'id')],
|
||||
'letter_type' => ['required', 'string', 'max:100'],
|
||||
|
||||
@ -23,4 +23,9 @@ public function user(): BelongsTo
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Models\Announcement;
|
||||
use App\Models\Student;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class AnnouncementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', ?int $departmentId = null): LengthAwarePaginator
|
||||
{
|
||||
return Announcement::query()
|
||||
@ -20,13 +26,27 @@ public function paginated(int $perPage = 25, string $search = '', ?int $departme
|
||||
|
||||
public function create(array $data): Announcement
|
||||
{
|
||||
return Announcement::create([
|
||||
$announcement = Announcement::create([
|
||||
'title' => $data['title'],
|
||||
'content' => $data['content'],
|
||||
'department_id' => $data['department_id'] ?? null,
|
||||
'enrollment_year' => $data['enrollment_year'] ?? null,
|
||||
'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
|
||||
|
||||
@ -3,10 +3,24 @@
|
||||
namespace App\Services\Admin\Services;
|
||||
|
||||
use App\Models\AcademicAdvisingLog;
|
||||
use App\Models\Lecturer;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
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
|
||||
{
|
||||
return AcademicAdvisingLog::query()
|
||||
|
||||
@ -4,11 +4,30 @@
|
||||
|
||||
use App\Enums\LetterStatus;
|
||||
use App\Models\LetterRequest;
|
||||
use App\Models\Student;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
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
|
||||
{
|
||||
return LetterRequest::query()
|
||||
|
||||
@ -5,13 +5,40 @@
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
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
|
||||
{
|
||||
return Notification::query()
|
||||
->where('user_id', $user->id)
|
||||
->with('creator.profile')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->paginate($perPage);
|
||||
|
||||
@ -11,6 +11,7 @@ public function up(): void
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('title', 150)->nullable();
|
||||
$table->text('content')->nullable();
|
||||
$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 lecturersRoute } from '@/routes/admin/users/lecturers';
|
||||
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: {
|
||||
navMain: (NavGroup | NavItem)[];
|
||||
navSecondary: { title: string; url: string; icon: Icon }[];
|
||||
} = {
|
||||
navMain: [
|
||||
const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
|
||||
|
||||
function buildNavMain({
|
||||
isMahasiswa,
|
||||
isDosen,
|
||||
}: {
|
||||
isMahasiswa: boolean;
|
||||
isDosen: boolean;
|
||||
}): (NavGroup | NavItem)[] {
|
||||
return [
|
||||
{
|
||||
name: 'Dasbor',
|
||||
url: '#',
|
||||
@ -106,7 +115,9 @@ const data: {
|
||||
items: [
|
||||
{
|
||||
name: 'Registrasi KRS',
|
||||
url: courseRegistrationsRoute.url(),
|
||||
url: isMahasiswa
|
||||
? studentCourseRegistrationsRoute.url()
|
||||
: courseRegistrationsRoute.url(),
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
@ -161,33 +172,43 @@ const data: {
|
||||
items: [
|
||||
{
|
||||
name: 'Surat Permohonan',
|
||||
url: letterRequestsRoute.url(),
|
||||
url: isMahasiswa
|
||||
? studentLetterRequestsRoute.url()
|
||||
: letterRequestsRoute.url(),
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
name: 'Bimbingan Akademik',
|
||||
url: academicAdvisingLogsRoute.url(),
|
||||
url: isDosen
|
||||
? lecturerAcademicAdvisingLogsRoute.url()
|
||||
: academicAdvisingLogsRoute.url(),
|
||||
icon: MessageCircle,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: 'Kritik dan Saran',
|
||||
url: feedbackRoute.url(),
|
||||
icon: IconMessageDots,
|
||||
},
|
||||
{
|
||||
title: 'Bantuan',
|
||||
url: '#',
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
};
|
||||
];
|
||||
}
|
||||
|
||||
const navSecondary: { title: string; url: string; icon: Icon }[] = [
|
||||
{
|
||||
title: 'Kritik dan Saran',
|
||||
url: feedbackRoute.url(),
|
||||
icon: IconMessageDots,
|
||||
},
|
||||
{
|
||||
title: 'Bantuan',
|
||||
url: '#',
|
||||
icon: IconHelp,
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@ -209,8 +230,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
<NavMain items={navMain} />
|
||||
<NavSecondary items={navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@ -6,6 +6,13 @@ import { useState } from 'react';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
|
||||
import type { Notification, NotificationPage } from '@/types/notification';
|
||||
@ -23,6 +30,7 @@ export function NotificationBell() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<Notification | null>(null);
|
||||
|
||||
const hasMore = notifications.current_page < notifications.last_page;
|
||||
const hasNotifications = notifications.data.length > 0;
|
||||
@ -47,6 +55,14 @@ export function NotificationBell() {
|
||||
router.delete(destroy(notification.id).url, { preserveScroll: true });
|
||||
}
|
||||
|
||||
function handleOpenDetail(notification: Notification) {
|
||||
setDetail(notification);
|
||||
|
||||
if (!notification.is_read) {
|
||||
handleMarkRead(notification);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
@ -70,9 +86,9 @@ export function NotificationBell() {
|
||||
|
||||
<ComboboxContent
|
||||
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>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@ -96,7 +112,7 @@ export function NotificationBell() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{hasNotifications ? (
|
||||
<div className="flex flex-col divide-y">
|
||||
{notifications.data.map((notification) => (
|
||||
@ -108,7 +124,13 @@ export function NotificationBell() {
|
||||
'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 && (
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
@ -117,11 +139,14 @@ export function NotificationBell() {
|
||||
{notification.title ?? '-'}
|
||||
</p>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{notification.creator
|
||||
?.profile?.full_name &&
|
||||
`${notification.creator.profile.full_name} · `}
|
||||
{format(
|
||||
new Date(
|
||||
notification.created_at,
|
||||
@ -130,7 +155,7 @@ export function NotificationBell() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!notification.is_read && (
|
||||
<Button
|
||||
@ -202,6 +227,35 @@ export function NotificationBell() {
|
||||
confirmLabel="Hapus"
|
||||
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;
|
||||
content: string | null;
|
||||
is_read: boolean;
|
||||
creator: { profile: { full_name: string } | null } | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@ -85,11 +85,25 @@
|
||||
});
|
||||
});
|
||||
|
||||
Route::middleware('role:mahasiswa')->prefix('course-registrations')->name('student.course-registrations.')->group(function () {
|
||||
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
|
||||
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
|
||||
Route::post('/', [CourseRegistrationController::class, 'store'])->name('store');
|
||||
Route::get('{submission}', [CourseRegistrationController::class, 'show'])->name('show');
|
||||
Route::middleware('role:mahasiswa')->group(function () {
|
||||
Route::prefix('course-registrations')->name('student.course-registrations.')->group(function () {
|
||||
Route::get('/', [CourseRegistrationController::class, 'mine'])->name('index');
|
||||
Route::get('create', [CourseRegistrationController::class, 'create'])->name('create');
|
||||
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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user