feat: enhance assignment management; implement new card and column components, improve pagination and filtering, and add lecturer information
This commit is contained in:
parent
35a64c2045
commit
56d607a131
@ -23,23 +23,15 @@ public function __construct(
|
|||||||
|
|
||||||
public function index(PaginatedRequest $request): Response
|
public function index(PaginatedRequest $request): Response
|
||||||
{
|
{
|
||||||
$academicTermId = $request->has('academic_term_id')
|
|
||||||
? $request->validated('academic_term_id')
|
|
||||||
: $this->academicTermService->getActive()?->id;
|
|
||||||
|
|
||||||
return Inertia::render('admin/academic-classes/assignments/index', [
|
return Inertia::render('admin/academic-classes/assignments/index', [
|
||||||
'assignments' => Inertia::scroll(fn () => $this->service->paginated(
|
'assignments' => $this->service->paginated(
|
||||||
$request->user(),
|
$request->user(),
|
||||||
...$request->validatedWithDefaults(),
|
...$request->validatedWithDefaults(),
|
||||||
courseClassId: $request->validated('course_class_id'),
|
courseClassId: $request->validated('course_class_id'),
|
||||||
academicTermId: $academicTermId,
|
academicTermId: $this->academicTermService->getActive()?->id,
|
||||||
)),
|
),
|
||||||
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
'courseClasses' => $this->courseClassService->getAllForSelect($request->user()),
|
||||||
'academicTerms' => $this->academicTermService->getAllForSelect(),
|
'filters' => $request->only(['course_class_id']),
|
||||||
'filters' => [
|
|
||||||
'course_class_id' => $request->validated('course_class_id'),
|
|
||||||
'academic_term_id' => $academicTermId ? (string) $academicTermId : null,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,13 +21,16 @@ public function __construct(
|
|||||||
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
public function paginated(User $user, int $perPage = 25, string $search = '', ?int $courseClassId = null, ?int $academicTermId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return Assignment::query()
|
return Assignment::query()
|
||||||
->select(['id', 'course_class_id', 'title', 'description', 'deadline', 'status'])
|
|
||||||
->withCount([
|
->withCount([
|
||||||
'submissions',
|
'submissions',
|
||||||
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
|
'submissions as graded_submissions_count' => fn ($q) => $q->whereNotNull('score'),
|
||||||
])
|
])
|
||||||
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
->with(['courseClass' => fn ($q) => $q->withCount('enrollments')
|
||||||
->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])])
|
->with([
|
||||||
|
'course:id,code,name',
|
||||||
|
'academicTerm:id,academic_year,semester,start_date,end_date',
|
||||||
|
'lecturer.user.profile',
|
||||||
|
])])
|
||||||
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) {
|
||||||
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
$q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at'])
|
||||||
->where('student_id', $user->student?->id);
|
->where('student_id', $user->student?->id);
|
||||||
@ -43,7 +46,7 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i
|
|||||||
});
|
});
|
||||||
}))
|
}))
|
||||||
->latest()
|
->latest()
|
||||||
->paginate($perPage);
|
->paginate($perPage,['id', 'course_class_id', 'title', 'description', 'deadline', 'status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(array $data, ?UploadedFile $file): Assignment
|
public function create(array $data, ?UploadedFile $file): Assignment
|
||||||
|
|||||||
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal file
225
resources/js/pages/admin/academic-classes/assignments/card.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
ClipboardList,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@/components/ui/accordion';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
||||||
|
import { formatAcademicTermLabel } from '@/types/academic-term';
|
||||||
|
import type { Assignment } from '@/types/assignment';
|
||||||
|
import { AssignmentStatusLabels } from '@/types/assignment';
|
||||||
|
import { deadlineTextClass, percentageOf } from './utils';
|
||||||
|
|
||||||
|
type CreateCardParams = {
|
||||||
|
handleEdit: (assignment: Assignment) => void;
|
||||||
|
handleDeleteClick: (assignment: Assignment) => void;
|
||||||
|
handleSubmitClick: (assignment: Assignment) => void;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
canViewSubmissions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAssignmentCard(params: CreateCardParams) {
|
||||||
|
const {
|
||||||
|
handleEdit,
|
||||||
|
handleDeleteClick,
|
||||||
|
handleSubmitClick,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
return function AssignmentCard(assignment: Assignment) {
|
||||||
|
const mySubmission = assignment.submissions?.[0];
|
||||||
|
const lecturerName =
|
||||||
|
assignment.course_class?.lecturer?.user?.profile?.full_name ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-base leading-tight font-medium">
|
||||||
|
{assignment.title}
|
||||||
|
</p>
|
||||||
|
{assignment.course_class && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{assignment.course_class.course?.code ?? ''}{' '}
|
||||||
|
{assignment.course_class.course?.name ?? ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lecturerName && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<User className="h-3 w-3 shrink-0" />
|
||||||
|
{lecturerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RowActions
|
||||||
|
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
mySubmission?.status === 'submitted'
|
||||||
|
? 'Kumpulkan Ulang'
|
||||||
|
: 'Kumpulkan Tugas',
|
||||||
|
icon: <Upload className="h-3.5 w-3.5" />,
|
||||||
|
show:
|
||||||
|
canSubmit && assignment.status === 'open',
|
||||||
|
onClick: () => handleSubmitClick(assignment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pengumpulan',
|
||||||
|
icon: (
|
||||||
|
<ClipboardList className="h-3.5 w-3.5" />
|
||||||
|
),
|
||||||
|
show: canViewSubmissions,
|
||||||
|
href: submissionsIndex.url(assignment.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||||
|
show: canUpdate,
|
||||||
|
onClick: () => handleEdit(assignment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hapus',
|
||||||
|
icon: (
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
),
|
||||||
|
show: canDelete,
|
||||||
|
onClick: () => handleDeleteClick(assignment),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
assignment.status === 'open'
|
||||||
|
? 'secondary'
|
||||||
|
: 'destructive'
|
||||||
|
}
|
||||||
|
className="w-fit text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{AssignmentStatusLabels[assignment.status]}
|
||||||
|
</Badge>
|
||||||
|
{assignment.course_class?.academic_term && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{formatAcademicTermLabel(
|
||||||
|
assignment.course_class.academic_term,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs ${deadlineTextClass(assignment.deadline)}`}
|
||||||
|
>
|
||||||
|
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{format(new Date(assignment.deadline), 'd MMM yyyy, HH:mm')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{assignment.attachment_url && assignment.attachment_name ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<AttachmentPreviewDialog
|
||||||
|
fileUrl={assignment.attachment_url}
|
||||||
|
fileName={assignment.attachment_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{canSubmit ? (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
mySubmission?.status === 'submitted'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mySubmission?.status === 'submitted'
|
||||||
|
? 'Sudah Mengumpulkan'
|
||||||
|
: 'Belum Mengumpulkan'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{assignment.submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
/{' '}
|
||||||
|
{(
|
||||||
|
assignment.course_class
|
||||||
|
?.enrollments_count ?? 0
|
||||||
|
).toLocaleString('id-ID')}{' '}
|
||||||
|
Pengumpulan (
|
||||||
|
{percentageOf(
|
||||||
|
assignment.submissions_count,
|
||||||
|
assignment.course_class
|
||||||
|
?.enrollments_count ?? 0,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{assignment.graded_submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
/{' '}
|
||||||
|
{assignment.submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
Dinilai (
|
||||||
|
{percentageOf(
|
||||||
|
assignment.graded_submissions_count,
|
||||||
|
assignment.submissions_count,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{assignment.description && (
|
||||||
|
<Accordion
|
||||||
|
type="single"
|
||||||
|
collapsible
|
||||||
|
className="-mx-6 -mb-6 border-t"
|
||||||
|
>
|
||||||
|
<AccordionItem
|
||||||
|
value="description"
|
||||||
|
className="border-b-0"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
|
||||||
|
Deskripsi
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6">
|
||||||
|
<p className="text-sm whitespace-pre-line text-foreground">
|
||||||
|
{assignment.description}
|
||||||
|
</p>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,213 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ClipboardList, Pencil, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { RowActions } from '@/components/row-actions';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
||||||
|
import type { Assignment } from '@/types/assignment';
|
||||||
|
import { AssignmentStatusLabels } from '@/types/assignment';
|
||||||
|
import { deadlineTextClass, percentageOf } from './utils';
|
||||||
|
|
||||||
|
export type { Assignment } from '@/types/assignment';
|
||||||
|
|
||||||
|
type CreateColumnsParams = {
|
||||||
|
handleEdit: (assignment: Assignment) => void;
|
||||||
|
handleDeleteClick: (assignment: Assignment) => void;
|
||||||
|
handleSubmitClick: (assignment: Assignment) => void;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
canViewSubmissions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAssignmentColumns(
|
||||||
|
params: CreateColumnsParams,
|
||||||
|
): ColumnDef<Assignment>[] {
|
||||||
|
const {
|
||||||
|
handleEdit,
|
||||||
|
handleDeleteClick,
|
||||||
|
handleSubmitClick,
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const columns: ColumnDef<Assignment>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: () => <span>Judul</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">
|
||||||
|
{row.getValue('title') as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'course_class.course.name',
|
||||||
|
header: () => <span>Kelas</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const courseClass = row.original.course_class;
|
||||||
|
|
||||||
|
if (!courseClass) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'deadline',
|
||||||
|
header: () => <span>Batas Waktu</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const assignment = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={deadlineTextClass(assignment.deadline)}>
|
||||||
|
{format(
|
||||||
|
new Date(assignment.deadline),
|
||||||
|
'd MMM yyyy, HH:mm',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: () => <span className="block text-center">Status</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'text-center',
|
||||||
|
headerClassName: 'text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const assignment = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
assignment.status === 'open'
|
||||||
|
? 'secondary'
|
||||||
|
: 'destructive'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{AssignmentStatusLabels[assignment.status]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'submissions',
|
||||||
|
header: () => <span>Pengumpulan</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const assignment = row.original;
|
||||||
|
|
||||||
|
if (canSubmit) {
|
||||||
|
const mySubmission = assignment.submissions?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
mySubmission?.status === 'submitted'
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mySubmission?.status === 'submitted'
|
||||||
|
? 'Sudah Mengumpulkan'
|
||||||
|
: 'Belum Mengumpulkan'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrollmentsCount =
|
||||||
|
assignment.course_class?.enrollments_count ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{assignment.submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
/ {enrollmentsCount.toLocaleString('id-ID')}{' '}
|
||||||
|
mengumpulkan (
|
||||||
|
{percentageOf(
|
||||||
|
assignment.submissions_count,
|
||||||
|
enrollmentsCount,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{assignment.graded_submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
/{' '}
|
||||||
|
{assignment.submissions_count.toLocaleString(
|
||||||
|
'id-ID',
|
||||||
|
)}{' '}
|
||||||
|
dinilai (
|
||||||
|
{percentageOf(
|
||||||
|
assignment.graded_submissions_count,
|
||||||
|
assignment.submissions_count,
|
||||||
|
)}
|
||||||
|
%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
columns.push({
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <span className="block text-center">Aksi</span>,
|
||||||
|
meta: {
|
||||||
|
className: 'w-[160px] text-center',
|
||||||
|
headerClassName: 'w-[160px] text-center',
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const assignment = row.original;
|
||||||
|
const mySubmission = assignment.submissions?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<RowActions
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label:
|
||||||
|
mySubmission?.status === 'submitted'
|
||||||
|
? 'Kumpulkan Ulang'
|
||||||
|
: 'Kumpulkan Tugas',
|
||||||
|
icon: <Upload className="h-4 w-4" />,
|
||||||
|
show:
|
||||||
|
canSubmit && assignment.status === 'open',
|
||||||
|
onClick: () => handleSubmitClick(assignment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pengumpulan',
|
||||||
|
icon: <ClipboardList className="h-4 w-4" />,
|
||||||
|
show: canViewSubmissions,
|
||||||
|
href: submissionsIndex.url(assignment.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
icon: <Pencil className="h-4 w-4" />,
|
||||||
|
show: canUpdate,
|
||||||
|
onClick: () => handleEdit(assignment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hapus',
|
||||||
|
icon: (
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
),
|
||||||
|
show: canDelete,
|
||||||
|
onClick: () => handleDeleteClick(assignment),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return columns;
|
||||||
|
}
|
||||||
@ -1,37 +1,18 @@
|
|||||||
import { Head, InfiniteScroll, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import { format } from 'date-fns';
|
import { Plus } from 'lucide-react';
|
||||||
import {
|
|
||||||
Clock,
|
|
||||||
ClipboardList,
|
|
||||||
Paperclip,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Upload,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AttachmentPreviewDialog } from '@/components/attachment-preview-dialog';
|
import { DataCards } from '@/components/data-cards';
|
||||||
|
import type { PaginationState } from '@/components/data-table';
|
||||||
|
import { DataTable } from '@/components/data-table';
|
||||||
import { DateTimeField } from '@/components/datetime-field';
|
import { DateTimeField } from '@/components/datetime-field';
|
||||||
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
|
||||||
import { FileUploadField } from '@/components/file-upload-field';
|
import { FileUploadField } from '@/components/file-upload-field';
|
||||||
import type {
|
import type { FilterOptionGroup } from '@/components/filter-dialog';
|
||||||
FilterField,
|
|
||||||
FilterOptionGroup,
|
|
||||||
} from '@/components/filter-dialog';
|
|
||||||
import { FilterDialog } from '@/components/filter-dialog';
|
import { FilterDialog } from '@/components/filter-dialog';
|
||||||
import { FormDialog } from '@/components/form-dialog';
|
import { FormDialog } from '@/components/form-dialog';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { RowActions } from '@/components/row-actions';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@/components/ui/accordion';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
ComboboxCollection,
|
ComboboxCollection,
|
||||||
@ -53,6 +34,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { ViewMode } from '@/components/view-toggle';
|
||||||
|
import { ViewToggle } from '@/components/view-toggle';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { useServerTable } from '@/hooks/use-server-table';
|
import { useServerTable } from '@/hooks/use-server-table';
|
||||||
import {
|
import {
|
||||||
@ -62,10 +45,10 @@ import {
|
|||||||
submit,
|
submit,
|
||||||
update,
|
update,
|
||||||
} from '@/routes/admin/academic-classes/assignments';
|
} from '@/routes/admin/academic-classes/assignments';
|
||||||
import { index as submissionsIndex } from '@/routes/admin/academic-classes/assignments/submissions';
|
|
||||||
import { formatAcademicTermLabel } from '@/types/academic-term';
|
|
||||||
import type { Assignment } from '@/types/assignment';
|
import type { Assignment } from '@/types/assignment';
|
||||||
import { AssignmentStatusLabels, AssignmentStatuses } from '@/types/assignment';
|
import { AssignmentStatuses, AssignmentStatusLabels } from '@/types/assignment';
|
||||||
|
import { createAssignmentCard } from './card';
|
||||||
|
import { createAssignmentColumns } from './columns';
|
||||||
|
|
||||||
type CourseClassOption = {
|
type CourseClassOption = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -80,12 +63,6 @@ type CourseClassOption = {
|
|||||||
|
|
||||||
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
type CourseClassGroup = { value: string; items: CourseClassOption[] };
|
||||||
|
|
||||||
type AcademicTermOption = {
|
|
||||||
id: number;
|
|
||||||
academic_year: string;
|
|
||||||
semester: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
assignments: {
|
assignments: {
|
||||||
data: Assignment[];
|
data: Assignment[];
|
||||||
@ -95,11 +72,9 @@ type Props = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
courseClasses: CourseClassOption[];
|
courseClasses: CourseClassOption[];
|
||||||
academicTerms: AcademicTermOption[];
|
|
||||||
highlight?: number;
|
highlight?: number;
|
||||||
filters: {
|
filters: {
|
||||||
course_class_id?: string;
|
course_class_id?: string;
|
||||||
academic_term_id?: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -107,29 +82,6 @@ function courseClassLabel(courseClass: CourseClassOption): string {
|
|||||||
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
return `${courseClass.course?.code ?? ''} - ${courseClass.course?.name ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function percentageOf(part: number, total: number): number {
|
|
||||||
return total > 0 ? Math.round((part / total) * 100) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns red as the deadline passes, amber once it's within 3 days. */
|
|
||||||
function deadlineTextClass(deadline: string): string {
|
|
||||||
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
|
|
||||||
|
|
||||||
if (hoursLeft <= 0) {
|
|
||||||
return 'font-medium text-destructive';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoursLeft <= 24) {
|
|
||||||
return 'text-destructive';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoursLeft <= 72) {
|
|
||||||
return 'text-amber-600 dark:text-amber-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text-muted-foreground';
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupCourseClassesByDepartment(
|
function groupCourseClassesByDepartment(
|
||||||
options: CourseClassOption[],
|
options: CourseClassOption[],
|
||||||
): CourseClassGroup[] {
|
): CourseClassGroup[] {
|
||||||
@ -209,7 +161,6 @@ function CourseClassField({
|
|||||||
export default function AssignmentIndex({
|
export default function AssignmentIndex({
|
||||||
assignments,
|
assignments,
|
||||||
courseClasses,
|
courseClasses,
|
||||||
academicTerms,
|
|
||||||
highlight,
|
highlight,
|
||||||
filters,
|
filters,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -217,6 +168,7 @@ export default function AssignmentIndex({
|
|||||||
const [editing, setEditing] = useState<Assignment | null>(null);
|
const [editing, setEditing] = useState<Assignment | null>(null);
|
||||||
const [deleting, setDeleting] = useState<Assignment | null>(null);
|
const [deleting, setDeleting] = useState<Assignment | null>(null);
|
||||||
const [submitting, setSubmitting] = useState<Assignment | null>(null);
|
const [submitting, setSubmitting] = useState<Assignment | null>(null);
|
||||||
|
const [view, setView] = useState<ViewMode>('table');
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canCreate = hasPermission('create-assignments');
|
const canCreate = hasPermission('create-assignments');
|
||||||
const canUpdate = hasPermission('update-assignments');
|
const canUpdate = hasPermission('update-assignments');
|
||||||
@ -224,15 +176,7 @@ export default function AssignmentIndex({
|
|||||||
const canViewSubmissions = hasPermission('view-assignment-submissions');
|
const canViewSubmissions = hasPermission('view-assignment-submissions');
|
||||||
const canSubmit = hasPermission('submit-assignments');
|
const canSubmit = hasPermission('submit-assignments');
|
||||||
|
|
||||||
const filterFields: FilterField[] = [
|
const filterFields = [
|
||||||
{
|
|
||||||
key: 'academic_term_id',
|
|
||||||
label: 'Periode Akademik',
|
|
||||||
options: academicTerms.map((term) => ({
|
|
||||||
value: String(term.id),
|
|
||||||
label: formatAcademicTermLabel(term),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'course_class_id',
|
key: 'course_class_id',
|
||||||
label: 'Kelas',
|
label: 'Kelas',
|
||||||
@ -241,33 +185,25 @@ export default function AssignmentIndex({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const pagination = {
|
const pagination: PaginationState = {
|
||||||
current_page: assignments.current_page,
|
current_page: assignments.current_page,
|
||||||
last_page: assignments.last_page,
|
last_page: assignments.last_page,
|
||||||
per_page: assignments.per_page,
|
per_page: assignments.per_page,
|
||||||
total: assignments.total,
|
total: assignments.total,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { search, handleSearchChange, applyFilters } = useServerTable({
|
const {
|
||||||
|
search,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
applyFilters,
|
||||||
|
} = useServerTable({
|
||||||
route: () => assignmentIndex.url(),
|
route: () => assignmentIndex.url(),
|
||||||
pagination,
|
pagination,
|
||||||
filters,
|
filters,
|
||||||
resetKeys: ['assignments'],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleApplyFilters(newFilters: Record<string, string>) {
|
|
||||||
// Tanpa `academic_term_id` eksplisit, backend akan kembali ke
|
|
||||||
// periode aktif, jadi menghapus filter ini perlu dikirim eksplisit
|
|
||||||
// alih-alih hanya menghilangkan key-nya.
|
|
||||||
const clearedAcademicTerm =
|
|
||||||
Boolean(filters.academic_term_id) && !newFilters.academic_term_id;
|
|
||||||
|
|
||||||
applyFilters({
|
|
||||||
...newFilters,
|
|
||||||
...(clearedAcademicTerm ? { academic_term_id: '' } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!deleting) {
|
if (!deleting) {
|
||||||
return;
|
return;
|
||||||
@ -278,6 +214,37 @@ export default function AssignmentIndex({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const columns = createAssignmentColumns({
|
||||||
|
handleEdit: (assignment) => setEditing(assignment),
|
||||||
|
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||||
|
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderAssignmentCard = createAssignmentCard({
|
||||||
|
handleEdit: (assignment) => setEditing(assignment),
|
||||||
|
handleDeleteClick: (assignment) => setDeleting(assignment),
|
||||||
|
handleSubmitClick: (assignment) => setSubmitting(assignment),
|
||||||
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
|
canSubmit,
|
||||||
|
canViewSubmissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbar = (
|
||||||
|
<>
|
||||||
|
<FilterDialog
|
||||||
|
fields={filterFields}
|
||||||
|
activeFilters={filters}
|
||||||
|
onApply={applyFilters}
|
||||||
|
/>
|
||||||
|
<ViewToggle value={view} onChange={setView} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Tugas" />
|
<Head title="Tugas" />
|
||||||
@ -351,256 +318,31 @@ export default function AssignmentIndex({
|
|||||||
assignment={submitting}
|
assignment={submitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{view === 'table' ? (
|
||||||
<Input
|
<DataTable
|
||||||
placeholder="Cari judul tugas..."
|
columns={columns}
|
||||||
value={search}
|
data={assignments.data}
|
||||||
onChange={(event) =>
|
searchKey="title"
|
||||||
handleSearchChange(event.target.value)
|
pagination={pagination}
|
||||||
}
|
onPageChange={handlePageChange}
|
||||||
className="max-w-sm"
|
onPerPageChange={handlePerPageChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchValue={search}
|
||||||
|
toolbar={toolbar}
|
||||||
/>
|
/>
|
||||||
<FilterDialog
|
|
||||||
fields={filterFields}
|
|
||||||
activeFilters={filters}
|
|
||||||
onApply={handleApplyFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignments.data.length === 0 ? (
|
|
||||||
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
|
||||||
Belum ada tugas.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<InfiniteScroll
|
<DataCards
|
||||||
data="assignments"
|
data={assignments.data}
|
||||||
as="div"
|
renderCard={renderAssignmentCard}
|
||||||
buffer={300}
|
getRowId={(assignment) => assignment.id}
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"
|
searchKey="title"
|
||||||
loading={() => (
|
pagination={pagination}
|
||||||
<p className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
onPageChange={handlePageChange}
|
||||||
Memuat tugas...
|
onPerPageChange={handlePerPageChange}
|
||||||
</p>
|
onSearchChange={handleSearchChange}
|
||||||
)}
|
searchValue={search}
|
||||||
>
|
toolbar={toolbar}
|
||||||
{assignments.data.map((assignment) => {
|
/>
|
||||||
const mySubmission = assignment.submissions?.[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={assignment.id}
|
|
||||||
className={
|
|
||||||
highlight === assignment.id
|
|
||||||
? 'ring-2 ring-primary'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<CardTitle className="text-base leading-tight">
|
|
||||||
{assignment.title}
|
|
||||||
</CardTitle>
|
|
||||||
{assignment.course_class && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{assignment.course_class
|
|
||||||
.course?.code ??
|
|
||||||
''}{' '}
|
|
||||||
{assignment.course_class
|
|
||||||
.course?.name ?? ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<RowActions
|
|
||||||
wrapperClassName="-mt-1 -mr-2 flex items-center gap-1"
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label:
|
|
||||||
mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'Kumpulkan Ulang'
|
|
||||||
: 'Kumpulkan Tugas',
|
|
||||||
icon: (
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show:
|
|
||||||
canSubmit &&
|
|
||||||
assignment.status ===
|
|
||||||
'open',
|
|
||||||
onClick: () =>
|
|
||||||
setSubmitting(
|
|
||||||
assignment,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pengumpulan',
|
|
||||||
icon: (
|
|
||||||
<ClipboardList className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show: canViewSubmissions,
|
|
||||||
href: submissionsIndex.url(
|
|
||||||
assignment.id,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
icon: (
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
),
|
|
||||||
show: canUpdate,
|
|
||||||
onClick: () =>
|
|
||||||
setEditing(assignment),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hapus',
|
|
||||||
icon: (
|
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
),
|
|
||||||
show: canDelete,
|
|
||||||
onClick: () =>
|
|
||||||
setDeleting(assignment),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
assignment.status === 'open'
|
|
||||||
? 'secondary'
|
|
||||||
: 'destructive'
|
|
||||||
}
|
|
||||||
className="w-fit text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{
|
|
||||||
AssignmentStatusLabels[
|
|
||||||
assignment.status
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</Badge>
|
|
||||||
{assignment.course_class
|
|
||||||
?.academic_term && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="w-fit text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{formatAcademicTermLabel(
|
|
||||||
assignment.course_class
|
|
||||||
.academic_term,
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 ${deadlineTextClass(assignment.deadline)}`}
|
|
||||||
>
|
|
||||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{format(
|
|
||||||
new Date(assignment.deadline),
|
|
||||||
'd MMM yyyy, HH:mm',
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{assignment.attachment_url &&
|
|
||||||
assignment.attachment_name ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Paperclip className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<AttachmentPreviewDialog
|
|
||||||
fileUrl={
|
|
||||||
assignment.attachment_url
|
|
||||||
}
|
|
||||||
fileName={
|
|
||||||
assignment.attachment_name
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
|
||||||
{canSubmit ? (
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mySubmission?.status ===
|
|
||||||
'submitted'
|
|
||||||
? 'Sudah Mengumpulkan'
|
|
||||||
: 'Belum Mengumpulkan'}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{assignment.submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
/{' '}
|
|
||||||
{(
|
|
||||||
assignment
|
|
||||||
.course_class
|
|
||||||
?.enrollments_count ??
|
|
||||||
0
|
|
||||||
).toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
Pengumpulan (
|
|
||||||
{percentageOf(
|
|
||||||
assignment.submissions_count,
|
|
||||||
assignment
|
|
||||||
.course_class
|
|
||||||
?.enrollments_count ??
|
|
||||||
0,
|
|
||||||
)}
|
|
||||||
%)
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{assignment.graded_submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
/{' '}
|
|
||||||
{assignment.submissions_count.toLocaleString(
|
|
||||||
'id-ID',
|
|
||||||
)}{' '}
|
|
||||||
Dinilai (
|
|
||||||
{percentageOf(
|
|
||||||
assignment.graded_submissions_count,
|
|
||||||
assignment.submissions_count,
|
|
||||||
)}
|
|
||||||
%)
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignment.description && (
|
|
||||||
<Accordion
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
className="-mx-6 -mb-6 border-t"
|
|
||||||
>
|
|
||||||
<AccordionItem
|
|
||||||
value="description"
|
|
||||||
className="border-b-0"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="px-6 text-xs font-medium text-foreground hover:no-underline">
|
|
||||||
Deskripsi
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-6">
|
|
||||||
<p className="text-sm whitespace-pre-line text-foreground">
|
|
||||||
{
|
|
||||||
assignment.description
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</InfiniteScroll>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
export function percentageOf(part: number, total: number): number {
|
||||||
|
return total > 0 ? Math.round((part / total) * 100) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns red as the deadline passes, amber once it's within 3 days. */
|
||||||
|
export function deadlineTextClass(deadline: string): string {
|
||||||
|
const hoursLeft = (new Date(deadline).getTime() - Date.now()) / 3_600_000;
|
||||||
|
|
||||||
|
if (hoursLeft <= 0) {
|
||||||
|
return 'font-medium text-destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoursLeft <= 24) {
|
||||||
|
return 'text-destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoursLeft <= 72) {
|
||||||
|
return 'text-amber-600 dark:text-amber-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-muted-foreground';
|
||||||
|
}
|
||||||
@ -27,6 +27,13 @@ export type Assignment = {
|
|||||||
academic_year: string;
|
academic_year: string;
|
||||||
semester: string;
|
semester: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
lecturer: {
|
||||||
|
id: number;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
profile: { full_name: string } | null;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
/** Total students enrolled (approved) in this class. */
|
/** Total students enrolled (approved) in this class. */
|
||||||
enrollments_count: number;
|
enrollments_count: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user