mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
fix: update the exam module
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user