Compare commits

...

2 Commits

17 changed files with 353 additions and 456 deletions

View File

@ -6,12 +6,10 @@
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;
@ -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 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.']);
@ -88,13 +58,4 @@ 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;
}
} }

View File

@ -9,40 +9,17 @@ class AcademicAdvisingLogRequest extends FormRequest
{ {
public function authorize(): bool 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'); return $this->user()->can($this->isMethod('post') ? 'create-academic-advising-logs' : 'update-academic-advising-logs');
} }
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_ids' => ['required', 'array', 'min:1'],
'student_ids.*' => ['integer', Rule::exists('students', 'id')],
'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')], 'lecturer_id' => ['required', 'integer', Rule::exists('lecturers', 'id')],
'topic' => ['nullable', 'string', 'max:150'], 'topic' => ['required', 'string', 'max:150'],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
'session_date' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'student_id.in' => 'Mahasiswa yang dipilih bukan mahasiswa bimbingan Anda.',
]; ];
} }
} }

View File

@ -6,22 +6,16 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Guarded(['id'])] #[Guarded(['id'])]
class AcademicAdvisingLog extends Model class AcademicAdvisingLog extends Model
{ {
use HasFactory; use HasFactory;
protected function casts(): array public function students(): BelongsToMany
{ {
return [ return $this->belongsToMany(Student::class);
'session_date' => 'datetime',
];
}
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
} }
public function lecturer(): BelongsTo public function lecturer(): BelongsTo

View File

@ -3,61 +3,53 @@
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
{ {
$user = auth()->user();
return AcademicAdvisingLog::query() return AcademicAdvisingLog::query()
->select(['id', 'student_id', 'lecturer_id', 'topic', 'notes', 'session_date', 'created_at']) ->select(['id', 'lecturer_id', 'topic', 'notes', 'created_at'])
->with([ ->with([
'student.user.profile', 'students.user.profile',
'student.department', 'students.department',
'lecturer.user.profile', 'lecturer.user.profile',
'lecturer.departments',
]) ])
->when($search, fn ($q) => $q->where('topic', 'like', "%{$search}%") ->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}%")))) ->orWhereHas('user.profile', fn ($q) => $q->where('full_name', 'like', "%{$search}%"))))
->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId)) ->when($lecturerId, fn ($q) => $q->where('lecturer_id', $lecturerId))
->when($user->hasRole('dosen'), fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))
->latest() ->latest()
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): AcademicAdvisingLog public function create(array $data): AcademicAdvisingLog
{ {
return AcademicAdvisingLog::create([ $log = AcademicAdvisingLog::create([
'student_id' => $data['student_id'],
'lecturer_id' => $data['lecturer_id'], 'lecturer_id' => $data['lecturer_id'],
'topic' => $data['topic'] ?? null, 'topic' => $data['topic'] ?? null,
'notes' => $data['notes'] ?? 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 public function update(AcademicAdvisingLog $log, array $data): AcademicAdvisingLog
{ {
$log->student_id = $data['student_id'];
$log->lecturer_id = $data['lecturer_id']; $log->lecturer_id = $data['lecturer_id'];
$log->topic = $data['topic'] ?? null; $log->topic = $data['topic'] ?? null;
$log->notes = $data['notes'] ?? null; $log->notes = $data['notes'] ?? null;
$log->session_date = $data['session_date'] ?? now();
$log->update(); $log->update();
$log->students()->sync($data['student_ids']);
return $log; return $log;
} }

View File

@ -10,11 +10,9 @@ public function up(): void
{ {
Schema::create('academic_advising_logs', function (Blueprint $table) { Schema::create('academic_advising_logs', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('student_id')->constrained()->cascadeOnDelete();
$table->foreignId('lecturer_id')->constrained()->cascadeOnDelete(); $table->foreignId('lecturer_id')->constrained()->cascadeOnDelete();
$table->string('topic', 150)->nullable(); $table->string('topic', 150)->nullable();
$table->text('notes')->nullable(); $table->text('notes')->nullable();
$table->timestamp('session_date')->nullable()->useCurrent();
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -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');
}
};

View File

@ -9,25 +9,30 @@ class AcademicAdvisingLogSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
AcademicAdvisingLog::insert([ $logs = [
[ [
'student_id' => 1,
'lecturer_id' => 1, 'lecturer_id' => 1,
'topic' => 'Konsultasi KRS Semester 5', 'topic' => 'Konsultasi KRS Semester 5',
'notes' => 'Mahasiswa mengambil 21 SKS, disetujui', 'notes' => 'Mahasiswa mengambil 21 SKS, disetujui',
'session_date' => '2026-01-20 13:00:00',
'created_at' => '2026-01-20 13:00:00', 'created_at' => '2026-01-20 13:00:00',
'updated_at' => '2026-01-20 13:00:00', 'updated_at' => '2026-01-20 13:00:00',
'student_ids' => [1],
], ],
[ [
'student_id' => 2,
'lecturer_id' => 2, 'lecturer_id' => 2,
'topic' => 'Bimbingan Tugas Akhir Bab 1', 'topic' => 'Bimbingan Tugas Akhir Bab 1',
'notes' => 'Perlu perbaikan rumusan masalah', 'notes' => 'Perlu perbaikan rumusan masalah',
'session_date' => '2026-07-15 13:00:00',
'created_at' => '2026-07-15 13:00:00', 'created_at' => '2026-07-15 13:00:00',
'updated_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);
}
} }
} }

View File

@ -50,6 +50,8 @@ public function run(): void
'dosen' => [ 'dosen' => [
'view-dashboard', 'view-dashboard',
'view-courses', 'view-courses',
'view-academic-advising-logs',
'view-students',
...$feedbackSelfService, ...$feedbackSelfService,
], ],
'staff-admin' => [ 'staff-admin' => [

View File

@ -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 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 studentCourseRegistrationsRoute } from '@/routes/student/course-registrations';
import type { Auth } from '@/types/auth'; import type { Auth } from '@/types/auth';
@ -62,11 +61,9 @@ const STAFF_ROLES = ['developer', 'staff-admin', 'staff-keuangan', 'kaprodi'];
function buildNavMain({ function buildNavMain({
isMahasiswa, isMahasiswa,
isDosen,
permissions, permissions,
}: { }: {
isMahasiswa: boolean; isMahasiswa: boolean;
isDosen: boolean;
permissions: string[]; permissions: string[];
}): (NavGroup | NavItem)[] { }): (NavGroup | NavItem)[] {
const can = (permission: string) => permissions.includes(permission); 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', name: 'Bimbingan Akademik',
url: isDosen url: academicAdvisingLogsRoute.url(),
? lecturerAcademicAdvisingLogsRoute.url()
: academicAdvisingLogsRoute.url(),
icon: MessageCircle, icon: MessageCircle,
}, },
] ]
@ -297,11 +292,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const roleNames = auth?.user?.roles?.map((role) => role.name) ?? []; const roleNames = auth?.user?.roles?.map((role) => role.name) ?? [];
const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role)); const isStaff = roleNames.some((role) => STAFF_ROLES.includes(role));
const isMahasiswa = !isStaff && roleNames.includes('mahasiswa'); const isMahasiswa = !isStaff && roleNames.includes('mahasiswa');
const isDosen = !isStaff && roleNames.includes('dosen');
const permissions = auth?.permissions ?? []; const permissions = auth?.permissions ?? [];
const navMain = buildNavMain({ const navMain = buildNavMain({
isMahasiswa, isMahasiswa,
isDosen,
permissions, permissions,
}); });

View File

@ -1,7 +1,3 @@
import { Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Info, Plus } from 'lucide-react';
import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
@ -31,6 +27,10 @@ import {
} from '@/routes/admin/feedback'; } from '@/routes/admin/feedback';
import type { Feedback } from '@/types/feedback'; import type { Feedback } from '@/types/feedback';
import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback'; import { FeedbackStatusLabels, FeedbackTypeLabels } from '@/types/feedback';
import { Head, Link, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Info, Plus } from 'lucide-react';
import { useState } from 'react';
import { createFeedbackColumns, FeedbackStatusVariants } from './columns'; import { createFeedbackColumns, FeedbackStatusVariants } from './columns';
type FeedbackTypeOption = { value: string; label: string }; type FeedbackTypeOption = { value: string; label: string };
@ -155,14 +155,16 @@ export default function FeedbackIndex({
feedback={viewing} feedback={viewing}
/> />
<Alert> {canUpdateStatus && (
<Info /> <Alert>
<AlertTitle>Ubah status Kritik dan Saran</AlertTitle> <Info />
<AlertDescription> <AlertTitle>Ubah status Kritik dan Saran</AlertTitle>
Klik badge Status pada tabel untuk mengubah status <AlertDescription>
Kritik dan Saran secara langsung. Klik badge Status pada tabel untuk mengubah status
</AlertDescription> Kritik dan Saran secara langsung.
</Alert> </AlertDescription>
</Alert>
)}
<DataTable <DataTable
columns={columns} columns={columns}

View File

@ -1,12 +1,12 @@
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns'; import { Eye, Pencil, Trash2 } from 'lucide-react';
import { Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions'; import { RowActions } from '@/components/row-actions';
import type { AcademicAdvisingLog } from '@/types/academic-advising-log'; import type { AcademicAdvisingLog } from '@/types/academic-advising-log';
export type { AcademicAdvisingLog } from '@/types/academic-advising-log'; export type { AcademicAdvisingLog } from '@/types/academic-advising-log';
type CreateColumnsParams = { type CreateColumnsParams = {
handleView: (log: AcademicAdvisingLog) => void;
handleEdit: (log: AcademicAdvisingLog) => void; handleEdit: (log: AcademicAdvisingLog) => void;
handleDeleteClick: (log: AcademicAdvisingLog) => void; handleDeleteClick: (log: AcademicAdvisingLog) => void;
canUpdate: boolean; canUpdate: boolean;
@ -16,24 +16,42 @@ type CreateColumnsParams = {
export function createAcademicAdvisingLogColumns( export function createAcademicAdvisingLogColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<AcademicAdvisingLog>[] { ): ColumnDef<AcademicAdvisingLog>[] {
const { handleEdit, handleDeleteClick, canUpdate, canDelete } = params; const { handleView, handleEdit, handleDeleteClick, canUpdate, canDelete } =
params;
const columns: ColumnDef<AcademicAdvisingLog>[] = [ const columns: ColumnDef<AcademicAdvisingLog>[] = [
{ {
accessorKey: 'student.student_number', accessorKey: 'students',
header: () => <span>Mahasiswa</span>, header: () => <span>Mahasiswa</span>,
cell: ({ row }) => { 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 ( return (
<div> <div className="space-y-1">
<p className="font-medium"> {visible.map((student) => (
{student?.user?.profile?.full_name ?? 'N/A'} <div key={student.id}>
</p> <p className="font-medium">
<p className="text-xs text-muted-foreground"> {student.user?.profile?.full_name ??
{student?.student_number} &middot;{' '} 'N/A'}
{student?.department?.name ?? '-'} </p>
</p> <p className="text-xs text-muted-foreground">
{student.student_number} &middot;{' '}
{student.department?.name ?? '-'}
</p>
</div>
))}
{remaining > 0 && (
<p className="text-xs text-muted-foreground">
+{remaining} mahasiswa lainnya
</p>
)}
</div> </div>
); );
}, },
@ -61,49 +79,41 @@ export function createAcademicAdvisingLogColumns(
header: () => <span>Topik</span>, header: () => <span>Topik</span>,
cell: ({ row }) => row.original.topic ?? '-', 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({
columns.push({ id: 'actions',
id: 'actions', header: () => <span className="block text-center">Aksi</span>,
header: () => <span className="block text-center">Aksi</span>, meta: {
meta: { className: 'w-[100px] text-center',
className: 'w-[100px] text-center', headerClassName: 'w-[100px] text-center',
headerClassName: 'w-[100px] text-center', },
}, cell: ({ row }) => (
cell: ({ row }) => ( <RowActions
<RowActions actions={[
actions={[ {
{ label: 'Lihat Detail',
label: 'Edit', icon: <Eye className="h-4 w-4" />,
icon: <Pencil className="h-4 w-4" />, onClick: () => handleView(row.original),
show: canUpdate, },
onClick: () => handleEdit(row.original), {
}, label: 'Edit',
{ icon: <Pencil className="h-4 w-4" />,
label: 'Hapus', show: canUpdate,
icon: ( onClick: () => handleEdit(row.original),
<Trash2 className="h-4 w-4 text-destructive" /> },
), {
show: canDelete, label: 'Hapus',
onClick: () => handleDeleteClick(row.original), icon: (
}, <Trash2 className="h-4 w-4 text-destructive" />
]} ),
/> show: canDelete,
), onClick: () => handleDeleteClick(row.original),
}); },
} ]}
/>
),
});
return columns; return columns;
} }

View File

@ -1,9 +1,9 @@
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import { format } from 'date-fns';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { DateTimeField } from '@/components/datetime-field';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog'; import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import type { FilterField } from '@/components/filter-dialog'; import type { FilterField } from '@/components/filter-dialog';
import { FilterDialog } 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 { Button } from '@/components/ui/button';
import { import {
Combobox, Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent, ComboboxContent,
ComboboxEmpty, ComboboxEmpty,
ComboboxInput, ComboboxInput,
ComboboxItem, ComboboxItem,
ComboboxList, ComboboxList,
useComboboxAnchor,
} from '@/components/ui/combobox'; } from '@/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@ -71,6 +81,7 @@ export default function AcademicAdvisingLogIndex({
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AcademicAdvisingLog | null>(null); const [editing, setEditing] = useState<AcademicAdvisingLog | null>(null);
const [deleting, setDeleting] = useState<AcademicAdvisingLog | null>(null); const [deleting, setDeleting] = useState<AcademicAdvisingLog | null>(null);
const [viewing, setViewing] = useState<AcademicAdvisingLog | null>(null);
const { hasPermission } = usePermissions(); const { hasPermission } = usePermissions();
const canCreate = hasPermission('create-academic-advising-logs'); const canCreate = hasPermission('create-academic-advising-logs');
const canUpdate = hasPermission('update-academic-advising-logs'); const canUpdate = hasPermission('update-academic-advising-logs');
@ -117,6 +128,7 @@ export default function AcademicAdvisingLogIndex({
} }
const columns = createAcademicAdvisingLogColumns({ const columns = createAcademicAdvisingLogColumns({
handleView: (log) => setViewing(log),
handleEdit: (log) => setEditing(log), handleEdit: (log) => setEditing(log),
handleDeleteClick: (log) => setDeleting(log), handleDeleteClick: (log) => setDeleting(log),
canUpdate, canUpdate,
@ -172,6 +184,16 @@ export default function AcademicAdvisingLogIndex({
lecturers={lecturers} lecturers={lecturers}
/> />
<ViewDetailDialog
open={viewing !== null}
onOpenChange={(open) => {
if (!open) {
setViewing(null);
}
}}
log={viewing}
/>
<DataTable <DataTable
columns={columns} columns={columns}
data={logs.data} data={logs.data}
@ -199,7 +221,7 @@ export default function AcademicAdvisingLogIndex({
}} }}
title="Hapus Log Bimbingan" title="Hapus Log Bimbingan"
description={(log) => 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} 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'}{' '}
&middot; {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'}{' '}
&middot; {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({ function AdvisingLogFields({
errors, errors,
editing, editing,
@ -219,49 +319,23 @@ function AdvisingLogFields({
students: AcademicAdvisingLogStudent[]; students: AcademicAdvisingLogStudent[];
lecturers: AcademicAdvisingLogLecturer[]; lecturers: AcademicAdvisingLogLecturer[];
}) { }) {
const [student, setStudent] = useState<AcademicAdvisingLogStudent | null>(
editing?.student ?? null,
);
const [lecturer, setLecturer] = const [lecturer, setLecturer] =
useState<AcademicAdvisingLogLecturer | null>(editing?.lecturer ?? null); 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 ( 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"> <div className="grid gap-2">
<Label> <Label>
Dosen Wali <span className="text-destructive">*</span> Dosen Wali <span className="text-destructive">*</span>
@ -274,7 +348,10 @@ function AdvisingLogFields({
<Combobox <Combobox
items={lecturers} items={lecturers}
value={lecturer} value={lecturer}
onValueChange={setLecturer} onValueChange={(value) => {
setLecturer(value);
setSelectedStudents([]);
}}
itemToStringLabel={(l) => lecturerLabel(l)} itemToStringLabel={(l) => lecturerLabel(l)}
isItemEqualToValue={(a, b) => a.id === b.id} isItemEqualToValue={(a, b) => a.id === b.id}
> >
@ -296,7 +373,86 @@ function AdvisingLogFields({
<InputError message={errors.lecturer_id} /> <InputError message={errors.lecturer_id} />
</div> </div>
<div className="grid gap-2"> <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 <Input
id={editing ? 'edit-topic' : 'topic'} id={editing ? 'edit-topic' : 'topic'}
name="topic" name="topic"
@ -317,13 +473,6 @@ function AdvisingLogFields({
/> />
<InputError message={errors.notes} /> <InputError message={errors.notes} />
</div> </div>
<DateTimeField
label="Tanggal Sesi"
name="session_date"
defaultValue={editing?.session_date}
placeholder="Pilih tanggal sesi"
error={errors.session_date}
/>
</> </>
); );
} }

View File

@ -1,6 +1,3 @@
import { Head, Link, router } from '@inertiajs/react';
import { FileSpreadsheet, Info, Plus, RotateCcw } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog';
import type { PaginationState } from '@/components/data-table'; import type { PaginationState } from '@/components/data-table';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
@ -16,15 +13,18 @@ import {
create, create,
destroy, destroy,
edit, edit,
exportMethod as exportStudents,
reset_password, reset_password,
index as studentsIndex,
update_status, update_status,
update_user_status, update_user_status,
exportMethod as exportStudents,
index as studentsIndex,
} from '@/routes/admin/users/students'; } from '@/routes/admin/users/students';
import { formatDepartmentLabel } from '@/types/department'; import { formatDepartmentLabel } from '@/types/department';
import { createStudentColumns } from './columns'; import { Head, Link, router } from '@inertiajs/react';
import { FileSpreadsheet, Info, Plus, RotateCcw } from 'lucide-react';
import { useState } from 'react';
import type { Student } from './columns'; import type { Student } from './columns';
import { createStudentColumns } from './columns';
type Department = { id: number; name: string; degree_level: string | null }; type Department = { id: number; name: string; degree_level: string | null };
type StatusOption = { value: string; label: string }; type StatusOption = { value: string; label: string };
@ -210,14 +210,16 @@ export default function StudentIndex({
} }
/> />
<Alert> {canUpdateAcademicStatus && (
<Info /> <Alert>
<AlertTitle>Ubah status mahasiswa</AlertTitle> <Info />
<AlertDescription> <AlertTitle>Ubah status mahasiswa</AlertTitle>
Klik badge Status pada tabel untuk mengubah status <AlertDescription>
mahasiswa secara langsung. Klik badge Status pada tabel untuk mengubah status
</AlertDescription> mahasiswa secara langsung.
</Alert> </AlertDescription>
</Alert>
)}
<DataTable <DataTable
columns={columns} columns={columns}

View File

@ -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')
: '-';
},
},
];

View File

@ -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>
);
}

View File

@ -9,17 +9,16 @@ export type AcademicAdvisingLogLecturer = {
id: number; id: number;
lecturer_number: string; lecturer_number: string;
user: { profile: { full_name: string } | null } | null; user: { profile: { full_name: string } | null } | null;
departments: { id: number; name: string }[];
}; };
export type AcademicAdvisingLog = { export type AcademicAdvisingLog = {
id: number; id: number;
student_id: number; students: AcademicAdvisingLogStudent[];
student: AcademicAdvisingLogStudent | null;
lecturer_id: number; lecturer_id: number;
lecturer: AcademicAdvisingLogLecturer | null; lecturer: AcademicAdvisingLogLecturer | null;
topic: string | null; topic: string | null;
notes: string | null; notes: string | null;
session_date: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };

View File

@ -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::prefix('admin/finances')->name('admin.finances.')->group(function () {
Route::resource('tuition-invoices', TuitionInvoiceController::class) Route::resource('tuition-invoices', TuitionInvoiceController::class)
->except(['create', 'edit', 'show']) ->except(['create', 'edit', 'show'])