feat: implement many-to-many relationship for academic advising logs and students
This commit is contained in:
parent
a6c6966cf1
commit
93ddee8fd6
@ -6,12 +6,10 @@
|
||||
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;
|
||||
|
||||
@ -36,36 +34,8 @@ 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.']);
|
||||
@ -88,13 +58,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,40 +9,17 @@ class AcademicAdvisingLogRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
if ($this->routeIs('lecturer.*')) {
|
||||
return (bool) $this->user()->lecturer;
|
||||
}
|
||||
|
||||
return $this->user()->can($this->isMethod('post') ? 'create-academic-advising-logs' : 'update-academic-advising-logs');
|
||||
}
|
||||
|
||||
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')],
|
||||
'student_ids' => ['required', 'array', 'min:1'],
|
||||
'student_ids.*' => ['integer', Rule::exists('students', 'id')],
|
||||
'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')],
|
||||
'topic' => ['nullable', 'string', 'max:150'],
|
||||
'topic' => ['required', 'string', 'max:150'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'session_date' => ['nullable', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'student_id.in' => 'Mahasiswa yang dipilih bukan mahasiswa bimbingan Anda.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,22 +6,16 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class AcademicAdvisingLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
public function students(): BelongsToMany
|
||||
{
|
||||
return [
|
||||
'session_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function student(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Student::class);
|
||||
return $this->belongsToMany(Student::class);
|
||||
}
|
||||
|
||||
public function lecturer(): BelongsTo
|
||||
|
||||
@ -3,61 +3,53 @@
|
||||
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
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return AcademicAdvisingLog::query()
|
||||
->select(['id', 'student_id', 'lecturer_id', 'topic', 'notes', 'session_date', 'created_at'])
|
||||
->select(['id', 'lecturer_id', 'topic', 'notes', 'created_at'])
|
||||
->with([
|
||||
'student.user.profile',
|
||||
'student.department',
|
||||
'students.user.profile',
|
||||
'students.department',
|
||||
'lecturer.user.profile',
|
||||
'lecturer.departments',
|
||||
])
|
||||
->when($search, fn ($q) => $q->where('topic', 'like', "%{$search}%")
|
||||
->orWhereHas('student', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
||||
->orWhereHas('students', fn ($q) => $q->where('student_number', 'like', "%{$search}%")
|
||||
->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
|
||||
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
|
||||
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
public function create(array $data): AcademicAdvisingLog
|
||||
{
|
||||
return AcademicAdvisingLog::create([
|
||||
'student_id' => $data['student_id'],
|
||||
$log = AcademicAdvisingLog::create([
|
||||
'lecturer_id' => $data['lecturer_id'],
|
||||
'topic' => $data['topic'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'session_date' => $data['session_date'] ?? now(),
|
||||
]);
|
||||
|
||||
$log->students()->attach($data['student_ids']);
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
|
||||
{
|
||||
$log->student_id = $data['student_id'];
|
||||
$log->lecturer_id = $data['lecturer_id'];
|
||||
$log->topic = $data['topic'] ?? null;
|
||||
$log->notes = $data['notes'] ?? null;
|
||||
$log->session_date = $data['session_date'] ?? now();
|
||||
$log->update();
|
||||
|
||||
$log->students()->sync($data['student_ids']);
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
|
||||
@ -10,11 +10,9 @@ public function up(): void
|
||||
{
|
||||
Schema::create('academic_advising_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('lecturer_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('topic', 150)->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamp('session_date')->nullable()->useCurrent();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
<?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::create('academic_advising_log_student', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('academic_advising_log_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('academic_advising_log_student');
|
||||
}
|
||||
};
|
||||
@ -9,25 +9,30 @@ class AcademicAdvisingLogSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
AcademicAdvisingLog::insert([
|
||||
$logs = [
|
||||
[
|
||||
'student_id' => 1,
|
||||
'lecturer_id' => 1,
|
||||
'topic' => 'Konsultasi KRS Semester 5',
|
||||
'notes' => 'Mahasiswa mengambil 21 SKS, disetujui',
|
||||
'session_date' => '2026-01-20 13:00:00',
|
||||
'created_at' => '2026-01-20 13:00:00',
|
||||
'updated_at' => '2026-01-20 13:00:00',
|
||||
'student_ids' => [1],
|
||||
],
|
||||
[
|
||||
'student_id' => 2,
|
||||
'lecturer_id' => 2,
|
||||
'topic' => 'Bimbingan Tugas Akhir Bab 1',
|
||||
'notes' => 'Perlu perbaikan rumusan masalah',
|
||||
'session_date' => '2026-07-15 13:00:00',
|
||||
'created_at' => '2026-07-15 13:00:00',
|
||||
'updated_at' => '2026-07-15 13:00:00',
|
||||
'student_ids' => [2],
|
||||
],
|
||||
]);
|
||||
];
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$studentIds = $log['student_ids'];
|
||||
unset($log['student_ids']);
|
||||
|
||||
AcademicAdvisingLog::create($log)->students()->attach($studentIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,8 @@ public function run(): void
|
||||
'dosen' => [
|
||||
'view-dashboard',
|
||||
'view-courses',
|
||||
'view-academic-advising-logs',
|
||||
'view-students',
|
||||
...$feedbackSelfService,
|
||||
],
|
||||
'staff-admin' => [
|
||||
|
||||
@ -54,7 +54,6 @@ 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 type { Auth } from '@/types/auth';
|
||||
|
||||
@ -62,11 +61,9 @@ const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
|
||||
|
||||
function buildNavMain({
|
||||
isMahasiswa,
|
||||
isDosen,
|
||||
permissions,
|
||||
}: {
|
||||
isMahasiswa: boolean;
|
||||
isDosen: boolean;
|
||||
permissions: string[];
|
||||
}): (NavGroup | NavItem)[] {
|
||||
const can = (permission: string) => permissions.includes(permission);
|
||||
@ -206,13 +203,11 @@ function buildNavMain({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isDosen || can('view-academic-advising-logs')
|
||||
...(can('view-academic-advising-logs')
|
||||
? [
|
||||
{
|
||||
name: 'Bimbingan Akademik',
|
||||
url: isDosen
|
||||
? lecturerAcademicAdvisingLogsRoute.url()
|
||||
: academicAdvisingLogsRoute.url(),
|
||||
url: academicAdvisingLogsRoute.url(),
|
||||
icon: MessageCircle,
|
||||
},
|
||||
]
|
||||
@ -297,11 +292,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
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 permissions = auth?.permissions ?? [];
|
||||
const navMain = buildNavMain({
|
||||
isMahasiswa,
|
||||
isDosen,
|
||||
permissions,
|
||||
});
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Eye, Pencil, Trash2 } from 'lucide-react';
|
||||
import { RowActions } from '@/components/row-actions';
|
||||
import type { AcademicAdvisingLog } from '@/types/academic-advising-log';
|
||||
|
||||
export type { AcademicAdvisingLog } from '@/types/academic-advising-log';
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleView: (log: AcademicAdvisingLog) => void;
|
||||
handleEdit: (log: AcademicAdvisingLog) => void;
|
||||
handleDeleteClick: (log: AcademicAdvisingLog) => void;
|
||||
canUpdate: boolean;
|
||||
@ -16,25 +16,43 @@ type CreateColumnsParams = {
|
||||
export function createAcademicAdvisingLogColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<AcademicAdvisingLog>[] {
|
||||
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params;
|
||||
const { handleView, handleEdit, handleDeleteClick, canUpdate, canDelete } =
|
||||
params;
|
||||
|
||||
const columns: ColumnDef<AcademicAdvisingLog>[] = [
|
||||
{
|
||||
accessorKey: 'student.student_number',
|
||||
accessorKey: 'students',
|
||||
header: () => <span>Mahasiswa</span>,
|
||||
cell: ({ row }) => {
|
||||
const student = row.original.student;
|
||||
const students = row.original.students;
|
||||
|
||||
if (students.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const visible = students.slice(0, 2);
|
||||
const remaining = students.length - visible.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{visible.map((student) => (
|
||||
<div key={student.id}>
|
||||
<p className="font-medium">
|
||||
{student?.user?.profile?.full_name ?? 'N/A'}
|
||||
{student.user?.profile?.full_name ??
|
||||
'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{student?.student_number} ·{' '}
|
||||
{student?.department?.name ?? '-'}
|
||||
{student.student_number} ·{' '}
|
||||
{student.department?.name ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{remaining} mahasiswa lainnya
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@ -61,20 +79,8 @@ export function createAcademicAdvisingLogColumns(
|
||||
header: () => <span>Topik</span>,
|
||||
cell: ({ row }) => row.original.topic ?? '-',
|
||||
},
|
||||
{
|
||||
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')
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (canUpdate || canDelete) {
|
||||
columns.push({
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
@ -85,6 +91,11 @@ export function createAcademicAdvisingLogColumns(
|
||||
cell: ({ row }) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{
|
||||
label: 'Lihat Detail',
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
onClick: () => handleView(row.original),
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Pencil className="h-4 w-4" />,
|
||||
@ -103,7 +114,6 @@ export function createAcademicAdvisingLogColumns(
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { format } from 'date-fns';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { PaginationState } from '@/components/data-table';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { DateTimeField } from '@/components/datetime-field';
|
||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||
import type { FilterField } from '@/components/filter-dialog';
|
||||
import { FilterDialog } from '@/components/filter-dialog';
|
||||
@ -13,12 +13,22 @@ import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
useComboboxAnchor,
|
||||
} from '@/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@ -71,6 +81,7 @@ export default function AcademicAdvisingLogIndex({
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AcademicAdvisingLog | null>(null);
|
||||
const [deleting, setDeleting] = useState<AcademicAdvisingLog | null>(null);
|
||||
const [viewing, setViewing] = useState<AcademicAdvisingLog | null>(null);
|
||||
const { hasPermission } = usePermissions();
|
||||
const canCreate = hasPermission('create-academic-advising-logs');
|
||||
const canUpdate = hasPermission('update-academic-advising-logs');
|
||||
@ -117,6 +128,7 @@ export default function AcademicAdvisingLogIndex({
|
||||
}
|
||||
|
||||
const columns = createAcademicAdvisingLogColumns({
|
||||
handleView: (log) => setViewing(log),
|
||||
handleEdit: (log) => setEditing(log),
|
||||
handleDeleteClick: (log) => setDeleting(log),
|
||||
canUpdate,
|
||||
@ -172,6 +184,16 @@ export default function AcademicAdvisingLogIndex({
|
||||
lecturers={lecturers}
|
||||
/>
|
||||
|
||||
<ViewDetailDialog
|
||||
open={viewing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setViewing(null);
|
||||
}
|
||||
}}
|
||||
log={viewing}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={logs.data}
|
||||
@ -199,7 +221,7 @@ export default function AcademicAdvisingLogIndex({
|
||||
}}
|
||||
title="Hapus Log Bimbingan"
|
||||
description={(log) =>
|
||||
`Apakah Anda yakin ingin menghapus log bimbingan untuk "${log.student?.user?.profile?.full_name ?? 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
`Apakah Anda yakin ingin menghapus log bimbingan untuk "${log.students.map((s) => s.user?.profile?.full_name ?? 'N/A').join(', ') || 'mahasiswa ini'}"? Tindakan ini tidak dapat dibatalkan.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
@ -208,6 +230,84 @@ export default function AcademicAdvisingLogIndex({
|
||||
);
|
||||
}
|
||||
|
||||
function ViewDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
log,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
log: AcademicAdvisingLog | null;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-lg">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Detail Bimbingan Akademik</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{log && (
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-x-hidden overflow-y-auto">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Dicatat{' '}
|
||||
{format(
|
||||
new Date(log.created_at),
|
||||
'd MMM yyyy, HH:mm',
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Dosen Wali
|
||||
</Label>
|
||||
<p>
|
||||
{log.lecturer?.user?.profile?.full_name ??
|
||||
'N/A'}{' '}
|
||||
· {log.lecturer?.lecturer_number}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Mahasiswa
|
||||
</Label>
|
||||
{log.students.length === 0 ? (
|
||||
<p className="text-muted-foreground">-</p>
|
||||
) : (
|
||||
<ul className="grid gap-1">
|
||||
{log.students.map((student) => (
|
||||
<li key={student.id}>
|
||||
{student.user?.profile
|
||||
?.full_name ?? 'N/A'}{' '}
|
||||
· {student.student_number}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Topik
|
||||
</Label>
|
||||
<p className="font-medium">{log.topic ?? '-'}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground">
|
||||
Catatan
|
||||
</Label>
|
||||
<p className="whitespace-pre-line">
|
||||
{log.notes ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvisingLogFields({
|
||||
errors,
|
||||
editing,
|
||||
@ -219,49 +319,23 @@ function AdvisingLogFields({
|
||||
students: AcademicAdvisingLogStudent[];
|
||||
lecturers: AcademicAdvisingLogLecturer[];
|
||||
}) {
|
||||
const [student, setStudent] = useState<AcademicAdvisingLogStudent | null>(
|
||||
editing?.student ?? null,
|
||||
);
|
||||
const [lecturer, setLecturer] =
|
||||
useState<AcademicAdvisingLogLecturer | null>(editing?.lecturer ?? null);
|
||||
const [selectedStudents, setSelectedStudents] = useState<
|
||||
AcademicAdvisingLogStudent[]
|
||||
>(editing?.students ?? []);
|
||||
const studentAnchor = useComboboxAnchor();
|
||||
|
||||
const availableStudents = lecturer
|
||||
? students.filter((s) =>
|
||||
lecturer.departments.some(
|
||||
(department) => department.id === s.department?.id,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Mahasiswa <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="student_id"
|
||||
value={student?.id ?? ''}
|
||||
/>
|
||||
<Combobox
|
||||
items={students}
|
||||
value={student}
|
||||
onValueChange={setStudent}
|
||||
itemToStringLabel={(s) => studentLabel(s)}
|
||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih mahasiswa"
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>
|
||||
Mahasiswa tidak ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(s: AcademicAdvisingLogStudent) => (
|
||||
<ComboboxItem key={s.id} value={s}>
|
||||
{studentLabel(s)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<InputError message={errors.student_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Dosen Wali <span className="text-destructive">*</span>
|
||||
@ -274,7 +348,10 @@ function AdvisingLogFields({
|
||||
<Combobox
|
||||
items={lecturers}
|
||||
value={lecturer}
|
||||
onValueChange={setLecturer}
|
||||
onValueChange={(value) => {
|
||||
setLecturer(value);
|
||||
setSelectedStudents([]);
|
||||
}}
|
||||
itemToStringLabel={(l) => lecturerLabel(l)}
|
||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||
>
|
||||
@ -296,7 +373,86 @@ function AdvisingLogFields({
|
||||
<InputError message={errors.lecturer_id} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={editing ? 'edit-topic' : 'topic'}>Topik</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Mahasiswa <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!lecturer}
|
||||
className="text-xs text-primary underline underline-offset-4 hover:text-primary/80 disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() =>
|
||||
setSelectedStudents(
|
||||
selectedStudents.length ===
|
||||
availableStudents.length
|
||||
? []
|
||||
: availableStudents,
|
||||
)
|
||||
}
|
||||
>
|
||||
{selectedStudents.length ===
|
||||
availableStudents.length &&
|
||||
availableStudents.length > 0
|
||||
? 'Batalkan Semua'
|
||||
: 'Pilih Semua'}
|
||||
</button>
|
||||
</div>
|
||||
{selectedStudents.map((s) => (
|
||||
<input
|
||||
key={s.id}
|
||||
type="hidden"
|
||||
name="student_ids[]"
|
||||
value={s.id}
|
||||
/>
|
||||
))}
|
||||
<Combobox
|
||||
items={availableStudents}
|
||||
multiple
|
||||
disabled={!lecturer}
|
||||
value={selectedStudents}
|
||||
onValueChange={setSelectedStudents}
|
||||
itemToStringLabel={(s) => studentLabel(s)}
|
||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||
>
|
||||
<ComboboxChips ref={studentAnchor}>
|
||||
{selectedStudents.map((s) => (
|
||||
<ComboboxChip
|
||||
key={s.id}
|
||||
aria-label={studentLabel(s)}
|
||||
>
|
||||
{studentLabel(s)}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
<ComboboxChipsInput
|
||||
disabled={!lecturer}
|
||||
placeholder={
|
||||
selectedStudents.length > 0
|
||||
? ''
|
||||
: lecturer
|
||||
? 'Pilih mahasiswa'
|
||||
: 'Pilih dosen terlebih dahulu'
|
||||
}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={studentAnchor}>
|
||||
<ComboboxEmpty>
|
||||
Mahasiswa tidak ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(s: AcademicAdvisingLogStudent) => (
|
||||
<ComboboxItem key={s.id} value={s}>
|
||||
{studentLabel(s)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<InputError message={errors.student_ids} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={editing ? 'edit-topic' : 'topic'}>
|
||||
Topik <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={editing ? 'edit-topic' : 'topic'}
|
||||
name="topic"
|
||||
@ -317,13 +473,6 @@ function AdvisingLogFields({
|
||||
/>
|
||||
<InputError message={errors.notes} />
|
||||
</div>
|
||||
<DateTimeField
|
||||
label="Tanggal Sesi"
|
||||
name="session_date"
|
||||
defaultValue={editing?.session_date}
|
||||
placeholder="Pilih tanggal sesi"
|
||||
error={errors.session_date}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
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')
|
||||
: '-';
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -1,156 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -9,17 +9,16 @@ export type AcademicAdvisingLogLecturer = {
|
||||
id: number;
|
||||
lecturer_number: string;
|
||||
user: { profile: { full_name: string } | null } | null;
|
||||
departments: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
export type AcademicAdvisingLog = {
|
||||
id: number;
|
||||
student_id: number;
|
||||
student: AcademicAdvisingLogStudent | null;
|
||||
students: AcademicAdvisingLogStudent[];
|
||||
lecturer_id: number;
|
||||
lecturer: AcademicAdvisingLogLecturer | null;
|
||||
topic: string | null;
|
||||
notes: string | null;
|
||||
session_date: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@ -139,11 +139,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
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::resource('tuition-invoices', TuitionInvoiceController::class)
|
||||
->except(['create', 'edit', 'show'])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user