Files
classroomio/apps/dashboard/src/lib/features/course/components/exercise/mark-exercise-modal.svelte
T
Richard Kennethandrichardkenneth f0695e9d18 fix(dashboard): Certain menus inside of modals are hidden (#875)
* fix: update z-index utility class for Select component across tag category and tutor selection views

* refactor: standardize z-index management using ui:z-menu-elevated, ui:z-app-bar, ui:z-app-bar-elevated and ui:z-modal classes across UI components

---------

Co-authored-by: richardkenneth <[email protected]>
2026-08-12 07:09:54 +01:00

416 lines
14 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte';
import { Badge } from '@cio/ui/base/badge';
import * as Select from '@cio/ui/base/select';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { STATUS } from './constants';
import { t } from '$lib/utils/functions/translations';
import type { SubmissionIdData } from '$features/course/utils/types';
import type { AnswerData } from '@cio/question-types';
import Preview from './preview.svelte';
import * as Dialog from '@cio/ui/base/dialog';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { Button } from '@cio/ui/base/button';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
// import { useCompletion } from 'ai/svelte';
// import { QUESTION_TYPE } from '$features/ui/question/constants';
interface Props {
open?: boolean;
onClose?: any;
handleSave?: any;
isGradeWithAI?: boolean;
data: SubmissionIdData;
deleteSubmission?: any;
isSaving?: boolean;
}
let {
open = $bindable(false),
onClose = () => {},
handleSave = (_d: SubmissionIdData) => {},
isGradeWithAI = $bindable(false),
data = $bindable(),
deleteSubmission = async (_id: string, _statusId: number) => {},
isSaving = false
}: Props = $props();
const SELECTABLE_STATUS = [
{
id: STATUS.SUBMITTED,
text: $t('course.navItem.submissions.submission_status.submitted')
},
{
id: STATUS.IN_PROGRESS,
text: $t('course.navItem.submissions.submission_status.in_progress')
},
{
id: STATUS.GRADED,
text: $t('course.navItem.submissions.submission_status.graded')
}
];
let status = $state(SELECTABLE_STATUS[0]);
let reasons = $state({});
let isLoading = $state(false);
let openDeletePrompt = $state(false);
let isDeleting = $state(false);
const maxPoints = $derived(getMaxPoints(data.questions));
const sortedQuestions = $derived(
Array.isArray(data.questions)
? [...data.questions]
.sort((a, b) => a.order - b.order)
.map((q) => ({
...q,
questionType: { id: q.questionTypeId }
}))
: []
);
let answersToDisplay = $state<Record<string, AnswerData>>({});
$effect(() => {
answersToDisplay = data?.answers ? (data.answers as Record<string, AnswerData>) : {};
});
function getMaxPoints(questions) {
return (questions || []).reduce((acc, question) => acc + question.points, 0);
}
let total = $derived(calculateTotal(data.questionAnswerByPoint));
function calculateTotal(grades: Record<string, string>): number {
if (!grades) return 0;
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade || '0'), 0);
}
function handleStatusChange(value: string) {
const newSelectedId = parseInt(value);
const newStatus = SELECTABLE_STATUS.find((s) => s.id === newSelectedId);
if (newStatus) {
status = newStatus;
data.statusId = newSelectedId;
}
}
function setStatus(data: SubmissionIdData) {
untrack(() => {
const statusBySelectedId = SELECTABLE_STATUS.find((s) => s.id === data.statusId);
if (statusBySelectedId) {
status = statusBySelectedId;
}
});
}
// const getMultipleAnswersGrade = (options, answer, points) => {
// const correctOptions = options
// .filter((option) => option.is_correct)
// .map((option) => option.value);
// const correctSelections = answer.answers.filter((answer) =>
// correctOptions.includes(answer)
// ).length;
// const incorrectSelections = answer.answers.length - correctSelections;
// const pointsEarned = (correctSelections / correctOptions.length) * points;
// const deduction = (incorrectSelections * points) / correctOptions.length;
// const finalPoints = Math.max(pointsEarned - deduction, 0);
// return {
// finalPoints,
// correctOptions,
// correctSelections,
// incorrectSelections
// };
// };
async function handleDeleteSubmission() {
isDeleting = true;
await deleteSubmission(data.id, status.id);
isDeleting = false;
}
// const { input, handleSubmit, completion } = useCompletion({
// api: '/api/completion/gradingprompt',
// onFinish: async () => {
// try {
// const responseData = $completion.replace('```json', '').replace('```', '');
// let aiResponses: {
// id: number;
// score: number;
// explanation: string;
// }[] = [];
// try {
// // Parse the modified response data as JSON
// aiResponses = JSON.parse(responseData);
// } catch (error) {
// console.error('Error parsing AI response', error);
// }
// data?.questions.forEach((question) => {
// const { id, points, question_type_id, options } = question;
// if (question_type_id == QUESTION_TYPE.RADIO) {
// const answer = data.questionAnswers.find((q) => q.question_id === id);
// const answerPoints = answer?.answers?.length ?? 0;
// reasons = {
// ...reasons,
// [id]: `${$t('course.navItem.submissions.grading_modal.allocated')} ${$t(
// 'course.navItem.submissions.grading_modal.total_try'
// )} ${answerPoints} `
// };
// data.questionAnswerByPoint[id] = `${Math.ceil(points / answerPoints)}`;
// } else if (question_type_id == QUESTION_TYPE.CHECKBOX) {
// const answer = data.questionAnswers.find((q) => q.question_id === id);
// const { finalPoints, correctOptions, correctSelections, incorrectSelections } =
// getMultipleAnswersGrade(options, answer, points);
// reasons = {
// ...reasons,
// [id]: `${$t(
// 'course.navItem.submissions.grading_modal.allocated'
// )} ${correctSelections} ${$t('course.navItem.submissions.grading_modal.out_of')} ${
// correctOptions.length
// } ${$t('course.navItem.submissions.grading_modal.options_correct')} ${$t(
// 'course.navItem.submissions.grading_modal.options_wrong'
// )} ${incorrectSelections} `
// };
// data.questionAnswerByPoint[id] = `${finalPoints}`;
// } else if (aiResponses.length) {
// const graded = aiResponses.find((res) => res.id === id);
// reasons = {
// ...reasons,
// [id]: `${graded?.explanation}`
// };
// data.questionAnswerByPoint[id] = `${graded?.score}`;
// }
// });
// } catch (error) {
// console.error('Error', error);
// } finally {
// isLoading = false;
// }
// }
// });
function gradeWithAI() {
// isGradeWithAI = true;
// isLoading = true;
// const paragraphAiInput = data.questions
// .filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
// .map((q) => {
// const answer = data.questionAnswers.find((qA) => qA.question_id === q.id); // { open_answer: '' }
// return {
// id: q.id,
// question: q.title,
// answer: answer?.open_answer,
// point: q.points
// };
// });
// $input = JSON.stringify(paragraphAiInput);
// handleSubmit({ preventDefault: () => {} });
}
$effect(() => {
setStatus(data);
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
>
<Dialog.Content class="max-h-[90vh]! w-4/5 max-w-6xl!">
<Dialog.Header class="py-2">
<Dialog.Title class="text-base font-semibold">{data.title}</Dialog.Title>
</Dialog.Header>
<div class="flex max-h-[calc(90vh-5rem)] justify-between gap-4 overflow-y-auto pr-4">
<div class="mt-2 min-h-0 min-w-0 flex-1">
{#if openDeletePrompt}
<div class="mx-auto w-96 rounded-md border border-gray-300 p-3">
<h1 class="text-lg dark:text-white">
{$t('delete_modal.content')}
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" onclick={() => (openDeletePrompt = false)} disabled={isDeleting}>
{$t('delete_modal.no')}
</Button>
<Button variant="outline" onclick={handleDeleteSubmission} loading={isDeleting}>
{$t('delete_modal.yes')}
</Button>
</div>
</div>
{:else}
<Preview
questions={sortedQuestions}
questionnaireMetaData={{
answers: answersToDisplay,
isFinished: true
}}
bind:grades={data.questionAnswerByPoint}
bind:reasons
bind:isGradeWithAI
bind:isLoading
disableGrading={false}
/>
{/if}
</div>
<div class="sticky top-0 mt-2 ml-4 min-h-0 w-2/5 max-w-[350px] shrink-0 self-start">
<div class="rounded-md border border-gray-300">
<div
class="flex items-center justify-between gap-1 border-t-0 border-r-0 border-b border-l-0 border-gray-300 p-3"
>
<p class="text-base dark:text-white">
{$t('course.navItem.submissions.grading_modal.details')}
{#if data.isEarly}
<Badge variant="success" class="ml-2">
{$t('course.navItem.submissions.grading_modal.early')}
</Badge>
{:else}
<Badge variant="destructive" class="ml-2">
{$t('course.navItem.submissions.grading_modal.late')}
</Badge>
{/if}
</p>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
class="text-red-600"
onclick={() => {
openDeletePrompt = true;
}}
>
{$t('delete_modal.label')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
<p class="text-sm font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.total_grade')}:
</p>
<Badge class="w-fit rounded-md bg-gray-100 font-semibold text-black dark:bg-neutral-700 dark:text-white">
{total}/{maxPoints}
</Badge>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Status</p>
<div class="flex items-center">
<p class="dark:text-white rounded-full w-5 h-5 bg-yellow-300 mr-2" />
<p class="dark:text-white">Grading</p>
</div>
</div> -->
<div class="flex items-center space-x-4 px-3 py-2 text-sm">
<p class="text-sm font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.student')}:
</p>
{#if data.student}
<div class="flex flex-row items-center justify-center rounded-md bg-gray-100 p-[6px] dark:bg-neutral-700">
<UserAvatar
src={data.student.avatarUrl}
alt={$t('course.navItem.submissions.grading_modal.student_avatar')}
class="h-5 w-5"
/>
<p class="ml-2 line-clamp-1 text-sm font-semibold dark:text-white">
{data.student.fullname}
</p>
</div>
{/if}
</div>
<!-- <div class="flex items-center space-x-4 text-sm px-3 py-2">
<p class="dark:text-white text-sm text-gray-500 font-semibold">Assesment Type:</p>
<Tag
class="dark:text-white font-semibold bg-gray-100 dark:bg-neutral-700 rounded-md text-black w-fit"
>Paragraph</Tag
>
</div> -->
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.status')}:
</p>
<Select.Root
type="single"
value={status.id.toString()}
onValueChange={(value) => {
if (value) {
handleStatusChange(value);
}
}}
>
<Select.Trigger class="w-full">
{status.text}
</Select.Trigger>
<Select.Content>
{#each SELECTABLE_STATUS as statusOption}
<Select.Item value={statusOption.id.toString()}>
{statusOption.text}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col items-start px-3 py-2 text-sm">
<p class="font-semibold text-gray-500 dark:text-white">
{$t('course.navItem.submissions.grading_modal.add_comment')}:
</p>
<TextareaField
placeholder={$t('course.navItem.submissions.grading_modal.add_comment_placeholder')}
bind:value={data.feedback}
/>
</div>
<div class="flex w-full flex-col space-y-3 px-3 py-2">
<Button variant="outline" onclick={gradeWithAI} class="hidden! w-full">
<img src="/ai.svg" alt={$t('course.navItem.submissions.grading_modal.ai_icon')} />
<p class="text-sm font-semibold">
{$t('course.navItem.submissions.grading_modal.grade_with_ai')}
</p>
</Button>
<Button
onclick={() => {
handleSave(data);
// onClose();
}}
loading={isSaving}
class="w-full"
>
{$t('course.navItem.submissions.grading_modal.submit_grades')}
</Button>
</div>
<!-- <div class="flex items-center text-sm p-3">
<p class="dark:text-white w-1/2">Teacher</p>
<p class="dark:text-white">rotimi-best</p>
</div> -->
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>