fix: update the exam module

This commit is contained in:
Mengisteab
2026-06-30 07:52:47 +00:00
parent faf1d95476
commit ae78c54c51
5 changed files with 417 additions and 314 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Stack,
@@ -39,10 +39,12 @@ import {
IconX,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useGetExamQuery, useUpdateExamMutation } from '../api/exam-api';
import { useCreateResultMutation } from '../../result/api/result-api';
import type { Exam, ExamStatus } from '../types/exam';
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { QuestionAssigner } from '../components/QuestionAssigner';
import { RecordResultModal } from '../../result/components/RecordResultModal';
import type { ExamStatus, QuestionBrief } from '../types/exam';
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
@@ -63,177 +65,30 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
function RecordResultModal({
exam,
opened,
onClose,
}: {
exam: Exam;
opened: boolean;
onClose: () => void;
}) {
const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({});
const [remark, setRemark] = useState('');
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
url: '/profiles',
params: { q: 'w=type:=:SEAFARER' },
});
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const seafarers = profilesRes?.items ?? [];
const questions = exam.questions ?? [];
const seafarerOptions = seafarers.map((s: any) => ({
value: s.id,
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
}));
const filteredOptions = seafarerSearch
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
: seafarerOptions;
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
const passed = totalScore >= exam.cuttingPoint;
const handleScoreChange = (questionId: string, value: number) => {
setScores((prev) => ({ ...prev, [questionId]: value }));
};
const handleSave = async () => {
if (!selectedSeafarerId) {
notify.error('Please select a seafarer');
return;
}
try {
const breakdowns = questions.map((q) => ({
questionId: q.id,
score: scores[q.id] ?? 0,
remark: '',
}));
await createResult({
seafarerId: selectedSeafarerId,
examId: exam.id,
resultBreakdowns: breakdowns,
totalScore,
remark: remark ? { en: remark, am: '' } : undefined,
}).unwrap();
notify.success(`Result recorded — ${passed ? 'PASSED' : 'FAILED'} (${totalScore}/${exam.cuttingPoint})`);
setSelectedSeafarerId(null);
setScores({});
setRemark('');
setSeafarerSearch('');
onClose();
} catch {
notify.error('Failed to save result');
}
};
return (
<Modal opened={opened} onClose={onClose} title={`Record Result — ${exam.title.en}`} size="lg" radius="lg">
<Stack gap="md">
<Select
label="Seafarer"
placeholder="Search and select a seafarer"
data={filteredOptions}
value={selectedSeafarerId}
onChange={(v) => {
setSelectedSeafarerId(v);
setScores({});
}}
searchable
onSearchChange={setSeafarerSearch}
size="sm"
required
/>
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label="Score per Question" labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Question</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Max Points</Table.Th>
<Table.Th>Score</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title.en}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput
value={scores[q.id] ?? 0}
onChange={(v) => handleScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">
<InfoRow label="Total Score" value={String(totalScore)} />
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} />
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
<Group gap={4} mt={2}>
{passed
? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></>
: <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>}
</Group>
</div>
</SimpleGrid>
</Paper>
<TextInput
label="Remark (optional)"
placeholder="Officer remarks"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
size="sm"
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} size="sm">Cancel</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
Save Result
</Button>
</Group>
</>
)}
{selectedSeafarerId && questions.length === 0 && (
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
No questions assigned to this exam. Assign questions first.
</Alert>
)}
</Stack>
</Modal>
);
}
export function ExamDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const printRef = useRef<HTMLDivElement>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
const [randomCount, setRandomCount] = useState(5);
const [updateExam] = useUpdateExamMutation();
const [assignQuestions, { isLoading: isAssigning }] = useAssignQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
const certifications = certRes?.items ?? [];
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
.filter((q) => q.certificationId === exam.certificationId && q.form === exam.form)
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allQuestions, exam?.certificationId, exam?.form]);
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError || !exam) {
@@ -245,7 +100,69 @@ export function ExamDetailPage() {
);
}
const openAssignModal = () => {
setDraftQuestions(exam.questions ?? []);
setRandomCount(5);
openAssign();
};
const handleRandomSelect = () => {
const assignedIds = new Set(draftQuestions.map((q) => q.id));
const currentTotal = draftQuestions.reduce((s, q) => s + Number(q.points), 0);
const cuttingPoint = Number(exam.cuttingPoint);
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
if (eligible.length === 0) {
notify.error('No eligible questions available for random selection');
return;
}
const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
if (maxPossible < cuttingPoint) {
notify.error(`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`);
return;
}
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
const targetCount = Math.min(randomCount, shuffled.length);
const picked = shuffled.slice(0, targetCount);
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
if (currentTotal + pickedTotal < cuttingPoint) {
const remaining = shuffled.slice(targetCount);
for (const q of remaining) {
if (currentTotal + pickedTotal >= cuttingPoint) break;
picked.push(q);
pickedTotal += q.points;
}
}
const msg = picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
setDraftQuestions([...draftQuestions, ...picked]);
notify.info(msg);
};
const handleAssign = async () => {
try {
const questionIds = draftQuestions.map((q) => q.id);
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
} catch {
notify.error('Failed to assign questions');
}
};
const handlePrint = async () => {
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
if (total < Number(exam.cuttingPoint)) {
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) return;
@@ -301,7 +218,8 @@ export function ExamDetailPage() {
setTimeout(() => printWindow.print(), 500);
};
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + q.points, 0);
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
const certName = exam.certification?.name?.en ?? certifications.find((c) => c.id === exam.certificationId)?.name?.en ?? '—';
return (
<Stack gap="md" ref={printRef}>
@@ -339,7 +257,7 @@ export function ExamDetailPage() {
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">Exam Details</Title>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label="Certification" value={exam.certification?.name?.en ?? exam.certificationId} />
<InfoRow label="Certification" value={certName} />
<InfoRow label="Type" value={TYPE_LABEL[exam.type] ?? exam.type} />
<InfoRow label="Form" value={FORM_LABEL[exam.form] ?? exam.form} />
<InfoRow label="Venue" value={exam.venue} />
@@ -362,10 +280,15 @@ export function ExamDetailPage() {
{/* Questions */}
<Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">Questions ({totalPoints} pts total)</Title>
<Group justify="space-between" mb="md">
<Title order={5}>Questions ({totalPoints} pts total)</Title>
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
Manage Questions
</Button>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
No questions assigned yet. Use the exam list to assign questions.
No questions assigned yet. Click "Manage Questions" to assign.
</Alert>
) : (
<Stack gap="md">
@@ -387,6 +310,56 @@ export function ExamDetailPage() {
</Paper>
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`Manage Questions — ${exam.title.en}`} size="xl" radius="lg">
<Stack gap="md">
{exam.selectionMethod === 'MANUAL' ? (
<>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="manual"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
Randomly select questions from the pool of {eligibleQuestions.length} eligible questions. The selection will automatically ensure total points meet the passing mark ({exam.cuttingPoint} pts).
</Text>
<Group gap="sm">
<NumberInput
placeholder="Count"
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select
</Button>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={draftQuestions}
onChange={setDraftQuestions}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button>
</Group>
</>
)}
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Stack,
@@ -26,16 +26,13 @@ import { useDisclosure } from '@mantine/hooks';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import {
useGetExamsQuery,
useCreateExamMutation,
useUpdateExamMutation,
useDeleteExamMutation,
useAssignQuestionsMutation,
} from '../api/exam-api';
import type { Exam } from '../types/exam';
import { QuestionAssigner } from '../components/QuestionAssigner';
const STATUS_COLOR: Record<string, string> = {
PENDING: 'gray',
@@ -150,31 +147,18 @@ function ExamForm({
export function ExamPage() {
const navigate = useNavigate();
const { data: certRes } = useGetCertificationsQuery();
const { data: qRes } = useGetQuestionsQuery();
const { data, isLoading, isError } = useGetExamsQuery();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
const [deleteExam] = useDeleteExamMutation();
const [assignQuestions] = useAssignQuestionsMutation();
const certifications = certRes?.items ?? [];
const allQuestions = qRes?.items ?? [];
const exams = data?.items ?? [];
const [editing, setEditing] = useState<Exam | null>(null);
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [assignTarget, setAssignTarget] = useState<Exam | null>(null);
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
const [randomCount, setRandomCount] = useState(5);
const eligibleQuestions = useMemo(() => {
if (!assignTarget) return [];
return allQuestions
.filter((q) => q.certificationId === assignTarget.certificationId && q.form === assignTarget.form)
.map((q) => ({ id: q.id, title: q.title, form: q.form, points: q.points }));
}, [allQuestions, assignTarget?.certificationId, assignTarget?.form]);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name.en }));
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-';
@@ -224,67 +208,6 @@ export function ExamPage() {
}
};
const handleRandomSelect = () => {
if (!assignTarget) return;
const assignedIds = new Set((assignTarget.questions ?? []).map((q) => q.id));
const currentTotal = (assignTarget.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
const cuttingPoint = Number(assignTarget.cuttingPoint);
const eligible = eligibleQuestions.filter((q) => !assignedIds.has(q.id));
if (eligible.length === 0) {
notify.error('No eligible questions available for random selection');
return;
}
const maxPossible = currentTotal + eligible.reduce((s, q) => s + Number(q.points), 0);
if (maxPossible < cuttingPoint) {
notify.error(`Even all eligible questions combined (${maxPossible} pts) cannot reach the passing mark (${cuttingPoint} pts). Adjust the cutting point or add more questions.`);
return;
}
const shuffled = [...eligible].sort(() => Math.random() - 0.5);
const targetCount = Math.min(randomCount, shuffled.length);
const picked = shuffled.slice(0, targetCount);
let pickedTotal = picked.reduce((s, q) => s + Number(q.points), 0);
if (currentTotal + pickedTotal < cuttingPoint) {
const remaining = shuffled.slice(targetCount);
for (const q of remaining) {
if (currentTotal + pickedTotal >= cuttingPoint) break;
picked.push(q);
pickedTotal += q.points;
}
}
const msg = picked.length > targetCount
? `Selected ${picked.length} questions (${picked.length - targetCount} extra added to meet the ${cuttingPoint} pts passing mark)`
: `Randomly selected ${picked.length} questions`;
setAssignTarget({
...assignTarget,
questions: [...(assignTarget.questions ?? []), ...picked],
});
notify.info(msg);
};
const handleAssign = async () => {
if (!assignTarget) return;
const totalPoints = (assignTarget.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
if (totalPoints < assignTarget.cuttingPoint) {
notify.error(`Total question points (${totalPoints}) is less than the passing mark (${assignTarget.cuttingPoint}). Add more questions or adjust the cutting point.`);
return;
}
try {
const questionIds = assignTarget.questions?.map((q) => q.id) ?? [];
await assignQuestions({ examId: assignTarget.id, questionIds, remark: undefined }).unwrap();
notify.success('Questions assigned');
closeAssign();
setAssignTarget(null);
} catch {
notify.error('Failed to assign questions');
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading exams" />;
@@ -341,9 +264,7 @@ export function ExamPage() {
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{exam.form}</Badge></Table.Td>
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
<Table.Td>
<Button size="xs" variant="subtle" onClick={() => { setAssignTarget(exam); openAssign(); }}>
{exam.questions?.length ?? 0} questions
</Button>
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{exam.status}</Badge>
@@ -379,62 +300,6 @@ export function ExamPage() {
<Button color="red" onClick={handleDelete} size="sm">Delete</Button>
</Group>
</Modal>
{/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`Assign Questions — ${assignTarget?.title?.en ?? ''}`} size="xl" radius="lg">
{assignTarget && (
<Stack gap="md">
{assignTarget.selectionMethod === 'MANUAL' ? (
<>
<QuestionAssigner
available={eligibleQuestions}
assigned={assignTarget.questions ?? []}
onChange={(updated) => {
setAssignTarget({ ...assignTarget, questions: updated });
}}
mode="manual"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
Randomly select questions from the pool of {eligibleQuestions.length} eligible questions. The selection will automatically ensure total points meet the passing mark ({assignTarget.cuttingPoint} pts).
</Text>
<Group gap="sm">
<NumberInput
placeholder="Count"
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
max={eligibleQuestions.length}
size="xs"
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select
</Button>
</Group>
<QuestionAssigner
available={eligibleQuestions}
assigned={assignTarget.questions ?? []}
onChange={(updated) => {
setAssignTarget({ ...assignTarget, questions: updated });
}}
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm">Save Assignments</Button>
</Group>
</>
)}
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -135,7 +135,7 @@ export function QuestionPage() {
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, description, form: values.form as QuestionForm, points: values.points, time }).unwrap();
await updateQ({ id: editing.id, certificationId: values.certificationId, title, /* description, */ form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success('Question updated');
} else {
await createQ({ certificationId: values.certificationId, title, description, form: values.form as QuestionForm, points: values.points, time }).unwrap();

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import {
Modal,
Stack,
Select,
Divider,
Table,
Text,
Badge,
NumberInput,
Paper,
SimpleGrid,
Group,
TextInput,
Button,
Alert,
} from '@mantine/core';
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useApiQuery } from '@ema-platform/api';
import { useCreateResultMutation } from '../api/result-api';
import type { Exam } from '../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
<Text fz="sm" fw={500}>{value || '—'}</Text>
</div>
);
}
export function RecordResultModal({
exam,
opened,
onClose,
}: {
exam: Exam;
opened: boolean;
onClose: () => void;
}) {
const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({});
const [questionRemarks, setQuestionRemarks] = useState<Record<string, string>>({});
const [remark, setRemark] = useState('');
const { data: profilesRes } = useApiQuery<{ total: number; items: any[] }>({
url: '/profiles',
params: { q: 'w=type:=:SEAFARER' },
});
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const seafarers = profilesRes?.items ?? [];
const questions = exam.questions ?? [];
const seafarerOptions = seafarers.map((s: any) => ({
value: s.id,
label: `${s.firstName} ${s.middleName ?? ''} ${s.lastName}`,
}));
const filteredOptions = seafarerSearch
? seafarerOptions.filter((o: any) => o.label.toLowerCase().includes(seafarerSearch.toLowerCase()))
: seafarerOptions;
const totalScore = questions.reduce((sum, q) => sum + (scores[q.id] ?? 0), 0);
const passed = totalScore >= exam.cuttingPoint;
const handleScoreChange = (questionId: string, value: number) => {
setScores((prev) => ({ ...prev, [questionId]: value }));
};
const handleQuestionRemarkChange = (questionId: string, value: string) => {
setQuestionRemarks((prev) => ({ ...prev, [questionId]: value }));
};
const handleSave = async () => {
if (!selectedSeafarerId) {
notify.error('Please select a seafarer');
return;
}
try {
const breakdowns = questions.map((q) => ({
questionId: q.id,
score: scores[q.id] ?? 0,
remark: questionRemarks[q.id] ?? '',
}));
await createResult({
seafarerId: selectedSeafarerId,
examId: exam.id,
resultBreakdowns: breakdowns,
totalScore,
remark: remark ? { en: remark, am: '' } : undefined,
}).unwrap();
notify.success(`Result recorded — ${passed ? 'PASSED' : 'FAILED'} (${totalScore}/${exam.cuttingPoint})`);
setSelectedSeafarerId(null);
setScores({});
setQuestionRemarks({});
setRemark('');
setSeafarerSearch('');
onClose();
} catch {
notify.error('Failed to save result');
}
};
return (
<Modal opened={opened} onClose={onClose} title={`Record Result — ${exam.title.en}`} size="90%" radius="lg">
<Stack gap="md">
<Select
label="Seafarer"
placeholder="Search and select a seafarer"
data={filteredOptions}
value={selectedSeafarerId}
onChange={(v) => {
setSelectedSeafarerId(v);
setScores({});
setQuestionRemarks({});
}}
searchable
onSearchChange={setSeafarerSearch}
size="sm"
required
/>
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label="Score per Question" labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Question</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Max Points</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Remark</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title.en}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput
value={scores[q.id] ?? 0}
onChange={(v) => handleScoreChange(q.id, Number(v))}
min={0}
max={q.points}
size="xs"
style={{ width: 80 }}
/>
</Table.Td>
<Table.Td>
<TextInput
placeholder="Remark (optional)"
value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs"
style={{ minWidth: 160 }}
/>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">
<InfoRow label="Total Score" value={String(totalScore)} />
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} />
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
<Group gap={4} mt={2}>
{passed
? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></>
: <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>}
</Group>
</div>
</SimpleGrid>
</Paper>
<TextInput
label="Remark (optional)"
placeholder="Officer remarks"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
size="sm"
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose} size="sm">Cancel</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}>
Save Result
</Button>
</Group>
</>
)}
{selectedSeafarerId && questions.length === 0 && (
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
No questions assigned to this exam. Assign questions first.
</Alert>
)}
</Stack>
</Modal>
);
}

View File

@@ -31,11 +31,14 @@ import {
IconCalendar,
IconClock,
IconMapPin,
IconPlus,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
PASSED: 'teal',
@@ -213,9 +216,30 @@ export function ResultPage() {
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [pickerExamId, setPickerExamId] = useState<string | null>(null);
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
const [recordExam, setRecordExam] = useState<Exam | null>(null);
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
const startRecord = () => {
const ex = exams.find((e) => e.id === pickerExamId);
if (!ex) {
notify.error('Please select an exam');
return;
}
setRecordExam(ex);
closePicker();
openRecord();
};
const handleRecordClose = () => {
closeRecord();
setRecordExam(null);
setPickerExamId(null);
};
const filtered = results.filter((r) => !examFilter || r.examId === examFilter);
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-';
@@ -242,10 +266,15 @@ export function ResultPage() {
return (
<Stack gap="lg">
<div>
<Title order={2}>Exam Results</Title>
<Text fz="sm" c="dimmed">View seafarer examination results and score breakdowns</Text>
</div>
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Exam Results</Title>
<Text fz="sm" c="dimmed">View seafarer examination results and score breakdowns</Text>
</div>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
Record Result
</Button>
</Group>
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
@@ -343,6 +372,31 @@ export function ResultPage() {
<Button color="red" onClick={handleDelete} size="sm">Delete</Button>
</Group>
</Modal>
{/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title="Record Result" size="md" radius="lg">
<Stack gap="md">
<Text fz="sm" c="dimmed">Choose the exam you want to record a result for.</Text>
<Select
label="Exam"
placeholder="Select an exam"
data={examOptions}
value={pickerExamId}
onChange={setPickerExamId}
size="sm"
searchable
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closePicker} size="sm">Cancel</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>Continue</Button>
</Group>
</Stack>
</Modal>
{recordExam && (
<RecordResultModal exam={recordExam} opened={recordOpened} onClose={handleRecordClose} />
)}
</Stack>
);
}