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

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