feat: implement i18n localization for exam components and detail page commit

This commit is contained in:
mengstabketemaw
2026-06-30 16:46:19 +03:00
parent 9fd59f52ed
commit 7f76c862fb
9 changed files with 721 additions and 243 deletions

View File

@@ -11,6 +11,7 @@ import {
Box, Box,
Button, Button,
} from '@mantine/core'; } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconSearch } from '@tabler/icons-react'; import { IconSearch } from '@tabler/icons-react';
import type { QuestionBrief } from '../types/exam'; import type { QuestionBrief } from '../types/exam';
@@ -36,13 +37,16 @@ function QuestionList({
onSearchChange: (v: string) => void; onSearchChange: (v: string) => void;
label: string; label: string;
}) { }) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const placeholder = t('exam.assigner.search');
return ( return (
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text> <Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
<Paper withBorder radius="md"> <Paper withBorder radius="md">
<Group p="sm" pb={0}> <Group p="sm" pb={0}>
<TextInput <TextInput
placeholder="Search..." placeholder={placeholder}
leftSection={<IconSearch size={14} />} leftSection={<IconSearch size={14} />}
value={search} value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)} onChange={(e) => onSearchChange(e.currentTarget.value)}
@@ -53,7 +57,7 @@ function QuestionList({
<ScrollArea h={280} p="sm" pt="xs"> <ScrollArea h={280} p="sm" pt="xs">
<Stack gap={4}> <Stack gap={4}>
{items.length === 0 && ( {items.length === 0 && (
<Text fz="xs" c="dimmed" ta="center" py="xl">No questions</Text> <Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
)} )}
{items.map((q) => ( {items.map((q) => (
<Paper <Paper
@@ -71,7 +75,7 @@ function QuestionList({
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" /> <Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text fz="xs" lineClamp={2}>{q.title.en}</Text> <Text fz="xs" lineClamp={2}>{q.title[locale]}</Text>
<Group gap={4} mt={2}> <Group gap={4} mt={2}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge> <Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge> <Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
@@ -88,11 +92,11 @@ function QuestionList({
} }
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) { export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
const { t } = useTranslation();
const [searchLeft, setSearchLeft] = useState(''); const [searchLeft, setSearchLeft] = useState('');
const [searchRight, setSearchRight] = useState(''); const [searchRight, setSearchRight] = useState('');
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set()); const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set()); const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
const assignedIds = new Set(assigned.map((q) => q.id)); const assignedIds = new Set(assigned.map((q) => q.id));
const filteredAvailable = available.filter( const filteredAvailable = available.filter(
@@ -115,8 +119,8 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
return ( return (
<Stack gap="sm"> <Stack gap="sm">
{mode === 'manual' && <Text fz="sm" fw={500}>Assign Questions to Exam</Text>} {mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
{mode === 'random' && <Text fz="sm" fw={500}>Assigned Questions</Text>} {mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
<Group gap="sm" align="stretch" wrap="nowrap"> <Group gap="sm" align="stretch" wrap="nowrap">
{mode === 'manual' && ( {mode === 'manual' && (
<QuestionList <QuestionList
@@ -129,7 +133,7 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
}} }}
search={searchLeft} search={searchLeft}
onSearchChange={setSearchLeft} onSearchChange={setSearchLeft}
label="Available Questions" label={t('exam.assigner.available')}
/> />
)} )}
<QuestionList <QuestionList
@@ -142,19 +146,19 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
}} }}
search={searchRight} search={searchRight}
onSearchChange={setSearchRight} onSearchChange={setSearchRight}
label="Assigned Questions" label={t('exam.assigner.assigned')}
/> />
</Group> </Group>
{mode === 'manual' && ( {mode === 'manual' && (
<Group gap="sm" justify="center"> <Group gap="sm" justify="center">
{selectedLeft.size > 0 && ( {selectedLeft.size > 0 && (
<Button size="xs" variant="light" onClick={assignSelected}> <Button size="xs" variant="light" onClick={assignSelected}>
Assign Selected ({selectedLeft.size}) {t('exam.assigner.assignSelected', { count: selectedLeft.size })}
</Button> </Button>
)} )}
{selectedRight.size > 0 && ( {selectedRight.size > 0 && (
<Button size="xs" variant="light" color="red" onClick={removeSelected}> <Button size="xs" variant="light" color="red" onClick={removeSelected}>
Remove Selected ({selectedRight.size}) {t('exam.assigner.removeSelected', { count: selectedRight.size })}
</Button> </Button>
)} )}
</Group> </Group>
@@ -162,7 +166,7 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
{mode === 'random' && selectedRight.size > 0 && ( {mode === 'random' && selectedRight.size > 0 && (
<Group gap="sm" justify="center"> <Group gap="sm" justify="center">
<Button size="xs" variant="light" color="red" onClick={removeSelected}> <Button size="xs" variant="light" color="red" onClick={removeSelected}>
Remove Selected ({selectedRight.size}) {t('exam.assigner.removeSelected', { count: selectedRight.size })}
</Button> </Button>
</Group> </Group>
)} )}

View File

@@ -24,6 +24,7 @@ import {
rem, rem,
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { import {
IconArrowLeft, IconArrowLeft,
IconPrinter, IconPrinter,
@@ -66,6 +67,8 @@ function InfoRow({ label, value }: { label: string; value: string }) {
} }
export function ExamDetailPage() { export function ExamDetailPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const printRef = useRef<HTMLDivElement>(null); const printRef = useRef<HTMLDivElement>(null);
@@ -94,8 +97,8 @@ export function ExamDetailPage() {
if (isError || !exam) { if (isError || !exam) {
return ( return (
<Stack gap="md"> <Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>Back to Exams</Button> <Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>Exam not found.</Alert> <Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
</Stack> </Stack>
); );
} }
@@ -180,25 +183,20 @@ export function ExamDetailPage() {
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq])); const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
const qHtml = (exam.questions ?? []).map((q, i) => { const qHtml = (exam.questions ?? []).map((q, i) => {
const full = qMap.get(q.id); const full = qMap.get(q.id);
const titleParts = [q.title.en]; const titleStr = q.title[locale] || q.title.en;
if (q.title.am) titleParts.push(q.title.am); const descStr = full?.description?.[locale] || full?.description?.en || '';
const titleStr = titleParts.join(' / ');
const desc = full?.description;
const descParts: string[] = [];
if (desc?.en) descParts.push(desc.en);
if (desc?.am) descParts.push(desc.am);
return ` return `
<div style="margin-bottom: 24px; page-break-inside: avoid;"> <div style="margin-bottom: 24px; page-break-inside: avoid;">
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p> <p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p> <p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
${descParts.length > 0 ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descParts.join(' / ')}</p>` : ''} ${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ''}
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''} ${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''} ${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
</div>`; </div>`;
}).join(''); }).join('');
printWindow.document.write(` printWindow.document.write(`
<html><head><title>${exam.title.en}</title> <html><head><title>${exam.title[locale] || exam.title.en}</title>
<style> <style>
body { font-family: sans-serif; padding: 40px; max-width: 800px; margin: auto; } body { font-family: sans-serif; padding: 40px; max-width: 800px; margin: auto; }
.header { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #333; padding-bottom: 16px; } .header { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #333; padding-bottom: 16px; }
@@ -211,12 +209,12 @@ export function ExamDetailPage() {
</style></head><body> </style></head><body>
<div class="header"> <div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''} ${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
<h1>${exam.title.en}${exam.title.am ? ' / ' + exam.title.am : ''}</h1> <h1>${exam.title[locale] || exam.title.en}</h1>
<p>Date: ${exam.date} | Venue: ${exam.venue}</p> <p>Date: ${exam.date} | Venue: ${exam.venue}</p>
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p> <p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p> <p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
</div> </div>
${exam.direction?.en || exam.direction?.am ? `<div class="directions"><strong>Directions:</strong>${[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')}</div>` : ''} ${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
${qHtml} ${qHtml}
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;"> <div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
Generated by EMA — Ethiopian Maritime Authority Generated by EMA — Ethiopian Maritime Authority
@@ -229,7 +227,7 @@ export function ExamDetailPage() {
}; };
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(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 ?? '—'; const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
return ( return (
<Stack gap="md" ref={printRef}> <Stack gap="md" ref={printRef}>
@@ -240,50 +238,45 @@ export function ExamDetailPage() {
<IconArrowLeft size={18} /> <IconArrowLeft size={18} />
</ActionIcon> </ActionIcon>
<div> <div>
<Title order={3}>{exam.title.en}</Title> <Title order={3}>{exam.title[locale]}</Title>
<Group gap={6} mt={2}>
<Text fz="sm" c="dimmed">{exam.title.am}</Text>
<Text fz="sm" c="dimmed">·</Text>
<Text fz="sm" c="dimmed">{exam.date}</Text>
</Group>
</div> </div>
</Group> </Group>
<Group gap="sm"> <Group gap="sm">
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm"> <Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
Print Exam {t('exam.print')}
</Button> </Button>
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm"> <Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
Record Result {t('exam.recordResult')}
</Button> </Button>
</Group> </Group>
</Group> </Group>
{/* Status badge */} {/* Status badge */}
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}> <Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
{exam.status} {t(`exam.status.${exam.status}`)}
</Badge> </Badge>
{/* Exam Info */} {/* Exam Info */}
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Title order={5} mb="md">Exam Details</Title> <Title order={5} mb="md">{t('exam.detail.title')}</Title>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<InfoRow label="Certification" value={certName} /> <InfoRow label={t('exam.detail.certification')} value={certName} />
<InfoRow label="Type" value={TYPE_LABEL[exam.type] ?? exam.type} /> <InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
<InfoRow label="Form" value={FORM_LABEL[exam.form] ?? exam.form} /> <InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
<InfoRow label="Venue" value={exam.venue} /> <InfoRow label={t('exam.detail.venue')} value={exam.venue} />
<InfoRow label="Date" value={exam.date} /> <InfoRow label={t('exam.detail.date')} value={exam.date} />
<InfoRow label="Administration" value={ADMIN_LABEL[exam.administrationMethod] ?? exam.administrationMethod} /> <InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
<InfoRow label="Evaluation" value={EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} /> <InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
<InfoRow label="Selection" value={exam.selectionMethod} /> <InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
<InfoRow label="Time Allowed" value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} /> <InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} /> <InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
<InfoRow label="Total Points" value={String(totalPoints)} /> <InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
<InfoRow label="Questions" value={String((exam.questions ?? []).length)} /> <InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
</SimpleGrid> </SimpleGrid>
{exam.direction?.en && ( {(exam.direction?.en || exam.direction?.am) && (
<> <>
<Divider my="md" /> <Divider my="md" />
<InfoRow label="Directions" value={exam.direction.en} /> <InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
</> </>
)} )}
</Paper> </Paper>
@@ -291,28 +284,27 @@ export function ExamDetailPage() {
{/* Questions */} {/* Questions */}
<Paper withBorder radius="lg" p="lg"> <Paper withBorder radius="lg" p="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={5}>Questions ({totalPoints} pts total)</Title> <Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}> <Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
Manage Questions {t('exam.manageQuestions')}
</Button> </Button>
</Group> </Group>
{(exam.questions ?? []).length === 0 ? ( {(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}> <Alert color="gray" icon={<IconInfoCircle size={16} />}>
No questions assigned yet. Click "Manage Questions" to assign. {t('exam.noQuestionsAssigned')}
</Alert> </Alert>
) : ( ) : (
<Stack gap="md"> <Stack gap="md">
{(exam.questions ?? []).map((q, i) => ( {(exam.questions ?? []).map((q, i) => (
<Paper key={q.id} withBorder p="md" radius="md"> <Paper key={q.id} withBorder p="md" radius="md">
<Group justify="space-between" mb="xs"> <Group justify="space-between" mb="xs">
<Text fz="sm" fw={700}>Question {i + 1}</Text> <Text fz="sm" fw={700}>{t('exam.detail.questionLabel')} {i + 1}</Text>
<Group gap={4}> <Group gap={4}>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{FORM_LABEL[q.form] ?? q.form}</Badge> <Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge> <Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
</Group> </Group>
</Group> </Group>
<Text fz="sm">{q.title.en}</Text> <Text fz="sm">{q.title[locale]}</Text>
{q.title.am && <Text fz="xs" c="dimmed" mt={2}>{q.title.am}</Text>}
</Paper> </Paper>
))} ))}
</Stack> </Stack>
@@ -322,7 +314,7 @@ export function ExamDetailPage() {
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} /> <RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */} {/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`Manage Questions — ${exam.title.en}`} size="xl" radius="lg"> <Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')}${exam.title[locale]}`} size="xl" radius="lg">
<Stack gap="md"> <Stack gap="md">
{exam.selectionMethod === 'MANUAL' ? ( {exam.selectionMethod === 'MANUAL' ? (
<> <>
@@ -333,18 +325,18 @@ export function ExamDetailPage() {
mode="manual" mode="manual"
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button> <Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button> <Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group> </Group>
</> </>
) : ( ) : (
<> <>
<Text fz="sm" c="dimmed"> <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). {t('exam.assigner.randomHint', { total: eligibleQuestions.length, pts: exam.cuttingPoint })}
</Text> </Text>
<Group gap="sm"> <Group gap="sm">
<NumberInput <NumberInput
placeholder="Count" placeholder={t('exam.assigner.selectCount')}
value={randomCount} value={randomCount}
onChange={(v) => setRandomCount(Number(v))} onChange={(v) => setRandomCount(Number(v))}
min={1} min={1}
@@ -353,7 +345,7 @@ export function ExamDetailPage() {
style={{ width: 80 }} style={{ width: 80 }}
/> />
<Button size="xs" variant="light" onClick={handleRandomSelect}> <Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select {t('exam.randomSelect')}
</Button> </Button>
</Group> </Group>
<QuestionAssigner <QuestionAssigner
@@ -363,8 +355,8 @@ export function ExamDetailPage() {
mode="random" mode="random"
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button> <Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button> <Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group> </Group>
</> </>
)} )}

View File

@@ -23,6 +23,7 @@ import {
Divider, Divider,
} from '@mantine/core'; } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react'; import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api'; import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -56,6 +57,7 @@ function ExamForm({
onSubmit: (values: any, isEdit: boolean) => void; onSubmit: (values: any, isEdit: boolean) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null); const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? ''); const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? ''); const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
@@ -89,56 +91,56 @@ function ExamForm({
return ( return (
<Paper p="md" withBorder mb="md" radius="md"> <Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md"> <Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md"> <Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>Basic Info</Tabs.Tab> <Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>Settings</Tabs.Tab> <Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="basic"> <Tabs.Panel value="basic">
<Stack gap="sm"> <Stack gap="sm">
<Select label="Certification" placeholder="Select certification" data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required /> <Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label="Title (English)" placeholder="Exam title in English" value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required /> <TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label="Title (Amharic)" placeholder="የፈተና ርዕስ" value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required /> <TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Textarea label="Direction (English)" placeholder="Instructions in English" value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} /> <Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label="Direction (Amharic)" placeholder="መመሪያ በአማርኛ" value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} /> <Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<TextInput label="Exam Date" type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required /> <TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
<TextInput label="Venue" placeholder="Exam venue" value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required /> <TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
<Text fz="sm" fw={500}>Time Allowed</Text> <Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
<Group gap="sm" grow> <Group gap="sm" grow>
<NumberInput label="Days" value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" /> <NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label="Hours" value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" /> <NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label="Minutes" value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" /> <NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group> </Group>
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="settings"> <Tabs.Panel value="settings">
<Stack gap="sm"> <Stack gap="sm">
<SimpleGrid cols={2} spacing="sm"> <SimpleGrid cols={2} spacing="sm">
<Select label="Type" placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: 'Written' }, { value: 'ORAL', label: 'Oral' }]} value={type} onChange={setType} size="sm" required /> <Select label={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
<Select label="Form" placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: 'Essay' }, { value: 'CHOICE', label: 'Choice' }]} value={form} onChange={setForm} size="sm" required /> <Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<Select label="Administration" placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: 'Offline' }, { value: 'ONLINE', label: 'Online' }]} value={adminMethod} onChange={setAdminMethod} size="sm" required /> <Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
<Select label="Evaluation Method" placeholder="How to compute score" data={[{ value: 'SUM', label: 'Sum' }, { value: 'AVERAGE', label: 'Average' }, { value: 'PERCENTAGE', label: 'Percentage' }]} value={evalMethod} onChange={setEvalMethod} size="sm" required /> <Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
<Select label="Selection Method" placeholder="Manual or Random" data={[{ value: 'MANUAL', label: 'Manual' }, { value: 'RANDOM', label: 'Random' }]} value={selMethod} onChange={setSelMethod} size="sm" /> <Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
<NumberInput label="Cutting Point (Pass Mark)" placeholder="Minimum score to pass" value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required /> <NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
</SimpleGrid> </SimpleGrid>
{editing && ( {editing && (
<Select label="Status" placeholder="Exam status" data={[ <Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
{ value: 'PENDING', label: 'Pending' }, { value: 'ACTIVE', label: 'Active' }, { value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
{ value: 'COMPLETED', label: 'Completed' }, { value: 'CANCELLED', label: 'Cancelled' }, { value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
{ value: 'POSTPONED', label: 'Postponed' }, { value: 'PUBLISHED', label: 'Published' }, { value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
]} value={status} onChange={setStatus} size="sm" /> ]} value={status} onChange={setStatus} size="sm" />
)} )}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button> <Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update Exam' : 'Create Exam'}</Button> <Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
</Group> </Group>
</form> </form>
</Paper> </Paper>
); );
@@ -146,6 +148,8 @@ function ExamForm({
export function ExamPage() { export function ExamPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: certRes } = useGetCertificationsQuery(); const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetExamsQuery(); const { data, isLoading, isError } = useGetExamsQuery();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation(); const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
@@ -160,8 +164,8 @@ export function ExamPage() {
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null); const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false); const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name.en })); const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.en ?? '-'; const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
const resetForm = () => { setEditing(null); setShowForm(false); }; const resetForm = () => { setEditing(null); setShowForm(false); };
@@ -185,14 +189,14 @@ export function ExamPage() {
try { try {
if (isEdit && editing) { if (isEdit && editing) {
await updateExam({ id: editing.id, ...payload }).unwrap(); await updateExam({ id: editing.id, ...payload }).unwrap();
notify.success('Exam updated'); notify.success(t('exam.updated'));
} else { } else {
await createExam(payload).unwrap(); await createExam(payload).unwrap();
notify.success('Exam created'); notify.success(t('exam.created'));
} }
resetForm(); resetForm();
} catch { } catch {
notify.error('Operation failed'); notify.error(t('exam.error'));
} }
}; };
@@ -200,27 +204,27 @@ export function ExamPage() {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
await deleteExam(deleteTarget.id).unwrap(); await deleteExam(deleteTarget.id).unwrap();
notify.success('Exam deleted'); notify.success(t('exam.deleted'));
closeDelete(); closeDelete();
setDeleteTarget(null); setDeleteTarget(null);
} catch { } catch {
notify.error('Failed to delete'); notify.error(t('exam.error'));
} }
}; };
if (isLoading) return <Center py="xl"><Loader /></Center>; if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading exams" />; if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <Group justify="space-between" align="flex-end">
<div> <div>
<Title order={2}>Examinations</Title> <Title order={2}>{t('exam.title')}</Title>
<Text fz="sm" c="dimmed">Manage exams, assign questions, and track results</Text> <Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
</div> </div>
{!showForm && ( {!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm"> <Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
Create Exam {t('exam.add')}
</Button> </Button>
)} )}
</Group> </Group>
@@ -239,14 +243,14 @@ export function ExamPage() {
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)"> <Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr> <Table.Tr>
<Table.Th>Title</Table.Th> <Table.Th>{t('exam.columns.title')}</Table.Th>
<Table.Th>Certification</Table.Th> <Table.Th>{t('exam.columns.certification')}</Table.Th>
<Table.Th>Date</Table.Th> <Table.Th>{t('exam.columns.date')}</Table.Th>
<Table.Th>Type</Table.Th> <Table.Th>{t('exam.columns.type')}</Table.Th>
<Table.Th>Form</Table.Th> <Table.Th>{t('exam.columns.form')}</Table.Th>
<Table.Th>Venue</Table.Th> <Table.Th>{t('exam.columns.venue')}</Table.Th>
<Table.Th>Questions</Table.Th> <Table.Th>{t('exam.columns.questions')}</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>{t('exam.columns.status')}</Table.Th>
<Table.Th /> <Table.Th />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -255,19 +259,19 @@ export function ExamPage() {
<Table.Tr key={exam.id}> <Table.Tr key={exam.id}>
<Table.Td> <Table.Td>
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}> <Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
{exam.title.en} {exam.title[locale]}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td> <Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td> <Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{exam.type}</Badge></Table.Td> <Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{t(`exam.type.${exam.type}`)}</Badge></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{exam.form}</Badge></Table.Td> <Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td> <Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
<Table.Td> <Table.Td>
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge> <Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{exam.status}</Badge> <Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs"> <Group gap="xs">
@@ -284,7 +288,7 @@ export function ExamPage() {
{exams.length === 0 && ( {exams.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={9}> <Table.Td colSpan={9}>
<Text c="dimmed" ta="center" py="xl">No exams found</Text> <Text c="dimmed" ta="center" py="xl">{t('exam.noItems')}</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -293,11 +297,11 @@ export function ExamPage() {
</Paper> </Paper>
{/* Delete confirmation */} {/* Delete confirmation */}
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Exam" size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
<Text mb="md">Are you sure you want to delete <strong>{deleteTarget?.title?.en}</strong>?</Text> <Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">Cancel</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">Delete</Button> <Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
</Group> </Group>
</Modal> </Modal>
</Stack> </Stack>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Modal, Modal,
Stack, Stack,
@@ -39,6 +40,8 @@ export function RecordResultModal({
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const [seafarerSearch, setSeafarerSearch] = useState(''); const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null); const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({}); const [scores, setScores] = useState<Record<string, number>>({});
@@ -77,7 +80,7 @@ export function RecordResultModal({
const handleSave = async () => { const handleSave = async () => {
if (!selectedSeafarerId) { if (!selectedSeafarerId) {
notify.error('Please select a seafarer'); notify.error(t('result.recordModal.seafarerRequired'));
return; return;
} }
try { try {
@@ -96,7 +99,7 @@ export function RecordResultModal({
if (passed && created.id) { if (passed && created.id) {
await updateResult({ id: created.id, status: 'PASSED' }).unwrap(); await updateResult({ id: created.id, status: 'PASSED' }).unwrap();
} }
notify.success(`Result recorded — ${passed ? 'PASSED' : 'FAILED'} (${totalScore}/${exam.cuttingPoint})`); notify.success(t('result.recordModal.saveSuccess'));
setSelectedSeafarerId(null); setSelectedSeafarerId(null);
setScores({}); setScores({});
setQuestionRemarks({}); setQuestionRemarks({});
@@ -104,16 +107,16 @@ export function RecordResultModal({
setSeafarerSearch(''); setSeafarerSearch('');
onClose(); onClose();
} catch { } catch {
notify.error('Failed to save result'); notify.error(t('result.recordModal.saveError'));
} }
}; };
return ( return (
<Modal opened={opened} onClose={onClose} title={`Record Result${exam.title.en}`} size="lg" radius="lg"> <Modal opened={opened} onClose={onClose} title={`${t('result.recordModal.title')}${exam.title[locale]}`} size="lg" radius="lg">
<Stack gap="md"> <Stack gap="md">
<Select <Select
label="Seafarer" label={t('result.recordModal.seafarer')}
placeholder="Search and select a seafarer" placeholder={t('result.recordModal.seafarerPlaceholder')}
data={filteredOptions} data={filteredOptions}
value={selectedSeafarerId} value={selectedSeafarerId}
onChange={(v) => { onChange={(v) => {
@@ -129,20 +132,20 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length > 0 && ( {selectedSeafarerId && questions.length > 0 && (
<> <>
<Divider label="Score per Question" labelPosition="center" /> <Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<Table striped> <Table striped>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Question</Table.Th> <Table.Th>{t('result.recordModal.question')}</Table.Th>
<Table.Th>Max Points</Table.Th> <Table.Th>{t('result.recordModal.maxPoints')}</Table.Th>
<Table.Th>Score</Table.Th> <Table.Th>{t('result.recordModal.score')}</Table.Th>
<Table.Th>Remark</Table.Th> <Table.Th>{t('result.recordModal.remark')}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{questions.map((q) => ( {questions.map((q) => (
<Table.Tr key={q.id}> <Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title.en}</Text></Table.Td> <Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td> <Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td> <Table.Td>
<NumberInput <NumberInput
@@ -156,7 +159,7 @@ export function RecordResultModal({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<TextInput <TextInput
placeholder="Remark (optional)" placeholder={t('result.recordModal.remarkOptional')}
value={questionRemarks[q.id] ?? ''} value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)} onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs" size="xs"
@@ -170,31 +173,31 @@ export function RecordResultModal({
<Paper withBorder p="sm" radius="md" bg="gray.0"> <Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm"> <SimpleGrid cols={3} spacing="sm">
<InfoRow label="Total Score" value={String(totalScore)} /> <InfoRow label={t('result.recordModal.totalScore')} value={String(totalScore)} />
<InfoRow label="Pass Mark" value={String(exam.cuttingPoint)} /> <InfoRow label={t('result.recordModal.passMark')} value={String(exam.cuttingPoint)} />
<div> <div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text> <Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{t('result.recordModal.status')}</Text>
<Group gap={4} mt={2}> <Group gap={4} mt={2}>
{passed {passed
? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></> ? <><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">{t('result.recordModal.passed')}</Text></>
: <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>} : <><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">{t('result.recordModal.failed')}</Text></>}
</Group> </Group>
</div> </div>
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
<TextInput <TextInput
label="Remark (optional)" label={t('result.recordModal.remarkOptional')}
placeholder="Officer remarks" placeholder={t('result.recordModal.remarkPlaceholder')}
value={remark} value={remark}
onChange={(e) => setRemark(e.currentTarget.value)} onChange={(e) => setRemark(e.currentTarget.value)}
size="sm" size="sm"
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={onClose} size="sm">Cancel</Button> <Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
<Button onClick={handleSave} size="sm" loading={isSaving}> <Button onClick={handleSave} size="sm" loading={isSaving}>
Save Result {t('result.saveResult')}
</Button> </Button>
</Group> </Group>
</> </>
@@ -202,7 +205,7 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length === 0 && ( {selectedSeafarerId && questions.length === 0 && (
<Alert color="yellow" icon={<IconInfoCircle size={15} />}> <Alert color="yellow" icon={<IconInfoCircle size={15} />}>
No questions assigned to this exam. Assign questions first. {t('result.recordModal.noQuestions')}
</Alert> </Alert>
)} )}
</Stack> </Stack>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, type ElementType } from 'react'; import { useState, useCallback, type ElementType } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Stack, Stack,
Title, Title,
@@ -92,6 +93,8 @@ function InfoRow({ label, value }: { label: string; value: string }) {
} }
export function ResultPage() { export function ResultPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: examRes } = useGetExamsQuery(); const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError } = useGetResultsQuery(); const { data, isLoading, isError } = useGetResultsQuery();
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery(); const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
@@ -121,7 +124,7 @@ export function ResultPage() {
const startRecord = () => { const startRecord = () => {
const ex = exams.find((e) => e.id === pickerExamId); const ex = exams.find((e) => e.id === pickerExamId);
if (!ex) { if (!ex) {
notify.error('Please select an exam'); notify.error(t('result.selectExamError'));
return; return;
} }
setRecordExam(ex); setRecordExam(ex);
@@ -155,7 +158,7 @@ export function ResultPage() {
? (results.reduce((s, r) => s + Number(r.totalScore || 0), 0) / total).toFixed(1) ? (results.reduce((s, r) => s + Number(r.totalScore || 0), 0) / total).toFixed(1)
: '0'; : '0';
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-'; const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.[locale] ?? '-';
const viewDetail = useCallback((result: Result) => { const viewDetail = useCallback((result: Result) => {
fetchDetail(result.id); fetchDetail(result.id);
@@ -175,10 +178,10 @@ export function ResultPage() {
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined, remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
resultBreakdowns: detailBreakdowns, resultBreakdowns: detailBreakdowns,
}).unwrap(); }).unwrap();
notify.success('Result updated'); notify.success(t('result.updated'));
closeDetail(); closeDetail();
} catch { } catch {
notify.error('Failed to update result'); notify.error(t('result.error'));
} finally { } finally {
setDetailSaving(false); setDetailSaving(false);
} }
@@ -195,42 +198,42 @@ export function ResultPage() {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
await deleteResult(deleteTarget.id).unwrap(); await deleteResult(deleteTarget.id).unwrap();
notify.success('Result deleted'); notify.success(t('result.deleted'));
closeDelete(); closeDelete();
setDeleteTarget(null); setDeleteTarget(null);
} catch { } catch {
notify.error('Failed to delete result'); notify.error(t('result.error'));
} }
}; };
if (isLoading) return <Center py="xl"><Loader /></Center>; if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading results" />; if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Group justify="space-between" align="flex-end"> <Group justify="space-between" align="flex-end">
<div> <div>
<Title order={2}>Exam Results</Title> <Title order={2}>{t('result.title')}</Title>
<Text fz="sm" c="dimmed">View seafarer examination results and score breakdowns</Text> <Text fz="sm" c="dimmed">{t('result.subtitle')}</Text>
</div> </div>
<Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm"> <Button leftSection={<IconPlus size={15} />} onClick={openPicker} size="sm">
Record Result {t('result.record')}
</Button> </Button>
</Group> </Group>
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg"> <SimpleGrid cols={{ base: 2, lg: 4 }} spacing="lg">
<ResultStat label="Total Results" value={String(total)} icon={IconClipboardList} color="blue" /> <ResultStat label={t('result.stats.totalResults')} value={String(total)} icon={IconClipboardList} color="blue" />
<ResultStat label="Passed" value={String(passedCount)} sub={`${passRate}%`} icon={IconCircleCheck} color="teal" /> <ResultStat label={t('result.stats.passed')} value={String(passedCount)} sub={`${passRate}%`} icon={IconCircleCheck} color="teal" />
<ResultStat label="Failed" value={String(failedCount)} sub={`${total ? 100 - passRate : 0}%`} icon={IconCircleX} color="red" /> <ResultStat label={t('result.stats.failed')} value={String(failedCount)} sub={`${total ? 100 - passRate : 0}%`} icon={IconCircleX} color="red" />
<ResultStat label="Avg Score" value={avgScore} icon={IconChartBar} color="indigo" /> <ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
</SimpleGrid> </SimpleGrid>
<Paper withBorder radius="md"> <Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm"> <Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Results</Text> <Text fw={600}>{t('result.section')}</Text>
<Group gap="sm" wrap="wrap"> <Group gap="sm" wrap="wrap">
<TextInput <TextInput
placeholder="Search seafarer..." placeholder={t('result.search.seafarer')}
leftSection={<IconSearch size={15} />} leftSection={<IconSearch size={15} />}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)} onChange={(e) => setSearchQuery(e.currentTarget.value)}
@@ -238,8 +241,8 @@ export function ResultPage() {
style={{ width: 240 }} style={{ width: 240 }}
/> />
<Select <Select
placeholder="Filter by exam" placeholder={t('result.search.filterByExam')}
data={[{ value: '', label: 'All Exams' }, ...examOptions]} data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
value={examFilter} value={examFilter}
onChange={(v) => setExamFilter(v ?? null)} onChange={(v) => setExamFilter(v ?? null)}
size="sm" size="sm"
@@ -252,11 +255,11 @@ export function ResultPage() {
<Table striped highlightOnHover> <Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)"> <Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr> <Table.Tr>
<Table.Th>Seafarer</Table.Th> <Table.Th>{t('result.columns.seafarer')}</Table.Th>
<Table.Th>Exam</Table.Th> <Table.Th>{t('result.columns.exam')}</Table.Th>
<Table.Th>Total Score</Table.Th> <Table.Th>{t('result.columns.totalScore')}</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>{t('result.columns.status')}</Table.Th>
<Table.Th>Date</Table.Th> <Table.Th>{t('result.columns.date')}</Table.Th>
<Table.Th /> <Table.Th />
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -268,7 +271,7 @@ export function ResultPage() {
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)} {r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td><Text fz="sm">{r.exam ? r.exam.title.en : getExamTitle(r.examId)}</Text></Table.Td> <Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td> <Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
<Table.Td> <Table.Td>
<Badge <Badge
@@ -283,7 +286,7 @@ export function ResultPage() {
/> />
} }
> >
{r.status} {t(`result.status.${r.status}`)}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td> <Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
@@ -295,7 +298,7 @@ export function ResultPage() {
leftSection={<IconEye size={13} />} leftSection={<IconEye size={13} />}
onClick={() => viewDetail(r)} onClick={() => viewDetail(r)}
> >
View / Edit {t('result.action.viewEdit')}
</Button> </Button>
<Button <Button
size="xs" size="xs"
@@ -304,7 +307,7 @@ export function ResultPage() {
leftSection={<IconTrash size={13} />} leftSection={<IconTrash size={13} />}
onClick={() => { setDeleteTarget(r); openDelete(); }} onClick={() => { setDeleteTarget(r); openDelete(); }}
> >
Delete {t('result.action.delete')}
</Button> </Button>
</Group> </Group>
</Table.Td> </Table.Td>
@@ -313,7 +316,7 @@ export function ResultPage() {
{filtered.length === 0 && ( {filtered.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={6}> <Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">No results found</Text> <Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
)} )}
@@ -324,7 +327,7 @@ export function ResultPage() {
<Modal <Modal
opened={detailOpened} opened={detailOpened}
onClose={handleDetailClose} onClose={handleDetailClose}
title="Result Detail" title={t('result.detail.title')}
size="xl" size="xl"
radius="lg" radius="lg"
> >
@@ -338,13 +341,13 @@ export function ResultPage() {
<ThemeIcon size="sm" variant="light" color="blue" radius="xl"> <ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconUser size={14} /> <IconUser size={14} />
</ThemeIcon> </ThemeIcon>
<Text fw={600} fz="sm">Seafarer Profile</Text> <Text fw={600} fz="sm">{t('result.detail.seafarerProfile')}</Text>
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label="Full Name" value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} /> <InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
<InfoRow label="Gender" value={detailResult.profile?.gender ?? '—'} /> <InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
<InfoRow label="Date of Birth" value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} /> <InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
<InfoRow label="Marital Status" value={detailResult.profile?.maritalStatus ?? '—'} /> <InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
@@ -355,15 +358,14 @@ export function ResultPage() {
<ThemeIcon size="sm" variant="light" color="violet" radius="xl"> <ThemeIcon size="sm" variant="light" color="violet" radius="xl">
<IconCertificate size={14} /> <IconCertificate size={14} />
</ThemeIcon> </ThemeIcon>
<Text fw={600} fz="sm">Exam Details</Text> <Text fw={600} fz="sm">{t('result.detail.examDetails')}</Text>
</Group> </Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm"> <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label="Exam Title" value={detailResult.exam.title?.en ?? '—'} /> <InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
<InfoRow label="Title (Amharic)" value={detailResult.exam.title?.am ?? '—'} /> <InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
<InfoRow label="Type" value={detailResult.exam.type ?? '—'} /> <InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
<InfoRow label="Venue" value={detailResult.exam.venue ?? '—'} /> <InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
<InfoRow label="Date" value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} /> <InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
<InfoRow label="Pass Mark" value={String(detailResult.exam.cuttingPoint ?? 0)} />
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
)} )}
@@ -372,15 +374,18 @@ export function ResultPage() {
{/* Editable fields */} {/* Editable fields */}
<Select <Select
label="Status" label={t('result.detail.status')}
data={['PASSED', 'FAILED']} data={[
{ value: 'PASSED', label: t('result.status.PASSED') },
{ value: 'FAILED', label: t('result.status.FAILED') },
]}
value={detailStatus} value={detailStatus}
onChange={(v) => setDetailStatus(v ?? 'PASSED')} onChange={(v) => setDetailStatus(v ?? 'PASSED')}
size="sm" size="sm"
/> />
<BilingualInput <BilingualInput
label="Remark" label={t('result.detail.remark')}
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }} placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
value={detailRemark} value={detailRemark}
onChange={setDetailRemark} onChange={setDetailRemark}
@@ -389,15 +394,15 @@ export function ResultPage() {
{detailBreakdowns.length > 0 && ( {detailBreakdowns.length > 0 && (
<> <>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">Score Breakdown</Text> <Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
<Table striped> <Table striped>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>#</Table.Th> <Table.Th>#</Table.Th>
<Table.Th>Question</Table.Th> <Table.Th>{t('result.detail.question')}</Table.Th>
<Table.Th>Max</Table.Th> <Table.Th>{t('result.detail.max')}</Table.Th>
<Table.Th>Score</Table.Th> <Table.Th>{t('result.detail.score')}</Table.Th>
<Table.Th>Remark</Table.Th> <Table.Th>{t('result.detail.remarkShort')}</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
@@ -409,7 +414,7 @@ export function ResultPage() {
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td> <Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
<Table.Td> <Table.Td>
<Text fz="xs" lineClamp={2} maw={200}> <Text fz="xs" lineClamp={2} maw={200}>
{q?.title?.en ?? b.questionId.slice(0, 8)} {q?.title?.[locale] ?? b.questionId.slice(0, 8)}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td> <Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
@@ -446,37 +451,37 @@ export function ResultPage() {
)} )}
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={handleDetailClose} size="sm">Close</Button> <Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
<Button <Button
onClick={handleDetailSave} onClick={handleDetailSave}
size="sm" size="sm"
loading={detailSaving} loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />} leftSection={<IconDeviceFloppy size={15} />}
> >
Save {t('result.save')}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed" ta="center" py="xl">No data available</Text> <Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
)} )}
</Modal> </Modal>
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Result" size="sm"> <Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
<Text mb="md">Are you sure you want to delete this result?</Text> <Text mb="md">{t('result.deleteConfirmText')}</Text>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">Cancel</Button> <Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">Delete</Button> <Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
</Group> </Group>
</Modal> </Modal>
{/* Choose exam, then record */} {/* Choose exam, then record */}
<Modal opened={pickerOpened} onClose={closePicker} title="Record Result" size="md" radius="lg"> <Modal opened={pickerOpened} onClose={closePicker} title={t('result.record')} size="md" radius="lg">
<Stack gap="md"> <Stack gap="md">
<Text fz="sm" c="dimmed">Choose the exam you want to record a result for.</Text> <Text fz="sm" c="dimmed">{t('result.detail.selectExam')}</Text>
<Select <Select
label="Exam" label={t('result.detail.exam')}
placeholder="Select an exam" placeholder={t('result.detail.selectExamPlaceholder')}
data={examOptions} data={examOptions}
value={pickerExamId} value={pickerExamId}
onChange={setPickerExamId} onChange={setPickerExamId}
@@ -485,8 +490,8 @@ export function ResultPage() {
required required
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={closePicker} size="sm">Cancel</Button> <Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>Continue</Button> <Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
</Group> </Group>
</Stack> </Stack>
</Modal> </Modal>

View File

@@ -29,6 +29,9 @@ export const am: Translations = {
locations: 'አካባቢዎች', locations: 'አካባቢዎች',
configuration: 'ውቅረት', configuration: 'ውቅረት',
profile: 'መገለጫ', profile: 'መገለጫ',
questions: 'ጥያቄዎች',
exams: 'ፈተናዎች',
examResults: 'የፈተና ውጤቶች',
collapseSidebar: 'ሰብስብ', collapseSidebar: 'ሰብስብ',
expandSidebar: 'ዘርጋ', expandSidebar: 'ዘርጋ',
}, },
@@ -96,6 +99,143 @@ export const am: Translations = {
}, },
}, },
exam: {
title: 'ፈተናዎች',
subtitle: 'ፈተናዎችን ያስተዳድሩ፣ ጥያቄዎችን ይመድቡ እና ውጤቶችን ይከታተሉ',
create: 'ፈተና ይፍጠሩ',
update: 'ፈተና ያዘምኑ',
add: 'ፈተና ይፍጠሩ',
noItems: 'ምንም ፈተናዎች አልተገኙም',
created: 'ፈተና ተፈጥሯል',
updated: 'ፈተና ዘምኗል',
deleted: 'ፈተና ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ፈተናዎችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
delete: 'ሰርዝ',
confirmDelete: 'ፈተና ይሰረዝ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
print: 'ፈተና ያትሙ',
recordResult: 'ውጤት ይመዝግቡ',
manageQuestions: 'ጥያቄዎችን ያስተዳድሩ',
saveAssignments: 'ምደባዎችን ያስቀምጡ',
randomSelect: 'በዘፈቀደ ይምረጡ',
backToExams: 'ወደ ፈተናዎች ይመለሱ',
notFound: 'ፈተና አልተገኘም።',
noQuestionsAssigned: 'ገና ምንም ጥያቄዎች አልተመደቡም።',
columns: {
title: 'ርዕስ',
certification: 'የምስክር ወረቀት',
date: 'ቀን',
type: 'አይነት',
form: 'ቅጽ',
venue: 'ቦታ',
questions: 'ጥያቄዎች',
status: 'ሁኔታ',
},
detail: {
title: 'የፈተና ዝርዝሮች',
certification: 'የምስክር ወረቀት',
type: 'አይነት',
form: 'ቅጽ',
venue: 'ቦታ',
date: 'ቀን',
administration: 'አስተዳደር',
evaluation: 'ግምገማ',
selection: 'ምርጫ',
timeAllowed: 'የተፈቀደ ጊዜ',
passMark: 'የማለፊያ ነጥብ',
totalPoints: 'ጠቅላላ ነጥብ',
questions: 'ጥያቄዎች',
directions: 'መመሪያዎች',
questionLabel: 'ጥያቄ',
questionsSection: 'ጥያቄዎች (ጠቅላላ {{pts}} ነጥብ)',
},
form: {
basicInfo: 'መሠረታዊ መረጃ',
settings: 'ቅንብሮች',
certification: 'የምስክር ወረቀት',
selectCertification: 'የምስክር ወረቀት ይምረጡ',
titleEn: 'ርዕስ (እንግሊዝኛ)',
titleEnPlaceholder: 'የፈተና ርዕስ በእንግሊዝኛ',
titleAm: 'ርዕስ (አማርኛ)',
titleAmPlaceholder: 'የፈተና ርዕስ',
directionEn: 'መመሪያ (እንግሊዝኛ)',
directionEnPlaceholder: 'መመሪያ በእንግሊዝኛ',
directionAm: 'መመሪያ (አማርኛ)',
directionAmPlaceholder: 'መመሪያ በአማርኛ',
examDate: 'የፈተና ቀን',
venue: 'ቦታ',
venuePlaceholder: 'የፈተና ቦታ',
timeAllowed: 'የተፈቀደ ጊዜ',
days: 'ቀናት',
hours: 'ሰአታት',
minutes: 'ደቂቃዎች',
written: 'ጽሑፍ',
oral: 'ቃል',
essay: 'ኢሴይ',
choice: 'ምርጫ',
offline: 'ከመስመር ውጪ',
online: 'በመስመር',
sum: 'ድምር',
average: 'አማካይ',
percentage: 'መቶኛ',
manual: 'በእጅ',
random: 'በዘፈቀደ',
cuttingPoint: 'የማለፊያ ነጥብ',
cuttingPointPlaceholder: 'ለማለፍ ዝቅተኛ ነጥብ',
status: 'ሁኔታ',
statusPlaceholder: 'የፈተና ሁኔታ',
pending: 'በመጠባበቅ ላይ',
active: 'ንቁ',
completed: 'ተጠናቋል',
cancelled: 'ተሰርዟል',
postponed: 'ተላልፏል',
published: 'ታትሟል',
},
assigner: {
title: 'ጥያቄዎችን ለፈተና ይመድቡ',
assignedTitle: 'የተመደቡ ጥያቄዎች',
available: 'የሚገኙ ጥያቄዎች',
assigned: 'የተመደቡ ጥያቄዎች',
search: 'ፈልግ...',
noQuestions: 'ምንም ጥያቄዎች የሉም',
assignSelected: 'የተመረጡትን ይመድቡ ({{count}})',
removeSelected: 'የተመረጡትን ያስወግዱ ({{count}})',
randomHint: 'ከጠቅላላ {{total}} ብቁ ጥያቄዎች ውስጥ በዘፈቀደ ይምረጡ። ምርጫው አጠቃላይ ነጥቦች የማለፊያ ነጥብ ({{pts}}) ላይ እንደሚደርሱ በራስ-ሰር ያረጋግጣል።',
selectCount: 'ብዛት',
},
status: {
PENDING: 'በመጠባበቅ ላይ',
ACTIVE: 'ንቁ',
COMPLETED: 'ተጠናቋል',
CANCELLED: 'ተሰርዟል',
POSTPONED: 'ተላልፏል',
PUBLISHED: 'ታትሟል',
},
type: {
WRITTEN: 'ጽሑፍ',
ORAL: 'ቃል',
},
formType: {
ESSAY: 'ኢሴይ',
CHOICE: 'ምርጫ',
},
admin: {
OFFLINE: 'ከመስመር ውጪ',
ONLINE: 'በመስመር',
},
eval: {
SUM: 'ድምር',
AVERAGE: 'አማካይ',
PERCENTAGE: 'መቶኛ',
},
selection: {
MANUAL: 'በእጅ',
RANDOM: 'በዘፈቀደ',
},
},
location: { location: {
title: 'አካባቢዎች', title: 'አካባቢዎች',
hierarchy: 'የአካባቢ ተዋረድ', hierarchy: 'የአካባቢ ተዋረድ',
@@ -262,6 +402,101 @@ export const am: Translations = {
}, },
}, },
result: {
title: 'የፈተና ውጤቶች',
subtitle: 'የመርከበኞችን የፈተና ውጤቶች እና የውጤት ክፍፍል ይመልከቱ',
record: 'ውጤት ያስመዝግቡ',
noItems: 'ምንም ውጤት አልተገኘም',
noData: 'ምንም መረጃ የለም',
created: 'ውጤት በተሳካ ሁኔታ ተመዝግቧል',
updated: 'ውጤት በተሳካ ሁኔታ ዘምኗል',
deleted: 'ውጤት በተሳካ ሁኔታ ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ውጤቶችን በማምጣት ላይ ስህተት',
cancel: 'ሰርዝ',
delete: 'ሰርዝ',
close: 'ዝጋ',
save: 'አስቀምጥ',
saveResult: 'ውጤት አስቀምጥ',
section: 'ውጤቶች',
selectExamError: 'እባክዎ ፈተና ይምረጡ',
continue: 'ቀጥል',
confirmDelete: 'ውጤት ሰርዝ',
deleteConfirmText: 'እርግጠኛ ነዎት ይህን ውጤት መሰረዝ ይፈልጋሉ?',
stats: {
totalResults: 'ጠቅላላ ውጤቶች',
passed: 'ያለፉ',
failed: 'ያልተሳኩ',
avgScore: 'አማካይ ውጤት',
},
columns: {
seafarer: 'መርከበኛ',
exam: 'ፈተና',
totalScore: 'ጠቅላላ ውጤት',
status: 'ሁኔታ',
date: 'ቀን',
},
detail: {
title: 'የውጤት ዝርዝር',
seafarerProfile: 'የመርከበኛ መገለጫ',
examDetails: 'የፈተና ዝርዝሮች',
fullName: 'ሙሉ ስም',
gender: 'ፆታ',
dateOfBirth: 'የትውልድ ቀን',
maritalStatus: 'የትዳር ሁኔታ',
examTitle: 'የፈተና ርዕስ',
titleAm: 'ርዕስ (አማርኛ)',
type: 'አይነት',
venue: 'ቦታ',
date: 'ቀን',
passMark: 'ማለፊያ ውጤት',
status: 'ሁኔታ',
remark: 'ማስታወሻ',
scoreBreakdown: 'የውጤት ክፍፍል',
question: 'ጥያቄ',
max: 'ከፍተኛ',
score: 'ውጤት',
remarkShort: 'ማስታወሻ',
selectExam: 'ውጤት ለመመዝገብ የሚፈልጉትን ፈተና ይምረጡ።',
exam: 'ፈተና',
selectExamPlaceholder: 'ፈተና ይምረጡ',
},
recordModal: {
title: 'ውጤት ያስመዝግቡ',
seafarer: 'መርከበኛ',
seafarerPlaceholder: 'መርከበኛ ይፈልጉ እና ይምረጡ',
scorePerQuestion: 'በጥያቄ ውጤት',
question: 'ጥያቄ',
maxPoints: 'ከፍተኛ ውጤት',
score: 'ውጤት',
remark: 'ማስታወሻ',
remarkOptional: 'ማስታወሻ (አማራጭ)',
remarkPlaceholder: 'የኦፊሰር ማስታወሻ',
totalScore: 'ጠቅላላ ውጤት',
passMark: 'ማለፊያ ውጤት',
status: 'ሁኔታ',
noQuestions: 'ለዚህ ፈተና ምንም ጥያቄዎች አልተመደቡም። መጀመሪያ ጥያቄዎችን ይመድቡ።',
seafarerRequired: 'እባክዎ መርከበኛ ይምረጡ',
saveSuccess: 'ውጤት ተመዝግቧል',
saveError: 'ውጤቱን ማስቀመጥ አልተሳካም',
passed: 'አልፏል',
failed: 'አልተሳካም',
},
status: {
PASSED: 'አልፏል',
FAILED: 'አልተሳካም',
},
action: {
viewEdit: 'ተመልከት / አስተካክል',
delete: 'ሰርዝ',
},
search: {
seafarer: 'መርከበኛ ይፈልጉ...',
filterByExam: 'በፈተና አጣራ',
allExams: 'ሁሉም ፈተናዎች',
},
},
question: { question: {
title: 'ጥያቄዎች', title: 'ጥያቄዎች',
pool: 'የጥያቄ ማከማቻ', pool: 'የጥያቄ ማከማቻ',

View File

@@ -27,6 +27,9 @@ export const en = {
locations: 'Locations', locations: 'Locations',
configuration: 'Configuration', configuration: 'Configuration',
profile: 'Profile', profile: 'Profile',
questions: 'Questions',
exams: 'Examinations',
examResults: 'Exam Results',
collapseSidebar: 'Collapse', collapseSidebar: 'Collapse',
expandSidebar: 'Expand sidebar', expandSidebar: 'Expand sidebar',
}, },
@@ -94,6 +97,143 @@ export const en = {
}, },
}, },
exam: {
title: 'Examinations',
subtitle: 'Manage exams, assign questions, and track results',
create: 'Create Exam',
update: 'Update Exam',
add: 'Create Exam',
noItems: 'No exams found',
created: 'Exam created',
updated: 'Exam updated',
deleted: 'Exam deleted',
error: 'Operation failed',
loadError: 'Error loading exams',
cancel: 'Cancel',
delete: 'Delete',
confirmDelete: 'Delete Exam',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
print: 'Print Exam',
recordResult: 'Record Result',
manageQuestions: 'Manage Questions',
saveAssignments: 'Save Assignments',
randomSelect: 'Randomly Select',
backToExams: 'Back to Exams',
notFound: 'Exam not found.',
noQuestionsAssigned: 'No questions assigned yet. Click "Manage Questions" to assign.',
columns: {
title: 'Title',
certification: 'Certification',
date: 'Date',
type: 'Type',
form: 'Form',
venue: 'Venue',
questions: 'Questions',
status: 'Status',
},
detail: {
title: 'Exam Details',
certification: 'Certification',
type: 'Type',
form: 'Form',
venue: 'Venue',
date: 'Date',
administration: 'Administration',
evaluation: 'Evaluation',
selection: 'Selection',
timeAllowed: 'Time Allowed',
passMark: 'Pass Mark',
totalPoints: 'Total Points',
questions: 'Questions',
directions: 'Directions',
questionLabel: 'Question',
questionsSection: 'Questions ({{pts}} pts total)',
},
form: {
basicInfo: 'Basic Info',
settings: 'Settings',
certification: 'Certification',
selectCertification: 'Select certification',
titleEn: 'Title (English)',
titleEnPlaceholder: 'Exam title in English',
titleAm: 'Title (Amharic)',
titleAmPlaceholder: 'የፈተና ርዕስ',
directionEn: 'Direction (English)',
directionEnPlaceholder: 'Instructions in English',
directionAm: 'Direction (Amharic)',
directionAmPlaceholder: 'መመሪያ በአማርኛ',
examDate: 'Exam Date',
venue: 'Venue',
venuePlaceholder: 'Exam venue',
timeAllowed: 'Time Allowed',
days: 'Days',
hours: 'Hours',
minutes: 'Minutes',
written: 'Written',
oral: 'Oral',
essay: 'Essay',
choice: 'Choice',
offline: 'Offline',
online: 'Online',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
manual: 'Manual',
random: 'Random',
cuttingPoint: 'Cutting Point (Pass Mark)',
cuttingPointPlaceholder: 'Minimum score to pass',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
active: 'Active',
completed: 'Completed',
cancelled: 'Cancelled',
postponed: 'Postponed',
published: 'Published',
},
assigner: {
title: 'Assign Questions to Exam',
assignedTitle: 'Assigned Questions',
available: 'Available Questions',
assigned: 'Assigned Questions',
search: 'Search...',
noQuestions: 'No questions',
assignSelected: 'Assign Selected ({{count}})',
removeSelected: 'Remove Selected ({{count}})',
randomHint: 'Randomly select questions from the pool of {{total}} eligible questions. The selection will automatically ensure total points meet the passing mark ({{pts}} pts).',
selectCount: 'Count',
},
status: {
PENDING: 'Pending',
ACTIVE: 'Active',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
POSTPONED: 'Postponed',
PUBLISHED: 'Published',
},
type: {
WRITTEN: 'Written',
ORAL: 'Oral',
},
formType: {
ESSAY: 'Essay',
CHOICE: 'Choice',
},
admin: {
OFFLINE: 'Offline',
ONLINE: 'Online',
},
eval: {
SUM: 'Sum',
AVERAGE: 'Average',
PERCENTAGE: 'Percentage',
},
selection: {
MANUAL: 'Manual',
RANDOM: 'Random',
},
},
location: { location: {
title: 'Locations', title: 'Locations',
hierarchy: 'Location Hierarchy', hierarchy: 'Location Hierarchy',
@@ -261,6 +401,101 @@ export const en = {
}, },
}, },
result: {
title: 'Exam Results',
subtitle: 'View seafarer examination results and score breakdowns',
record: 'Record Result',
noItems: 'No results found',
noData: 'No data available',
created: 'Result recorded',
updated: 'Result updated',
deleted: 'Result deleted',
error: 'Operation failed',
loadError: 'Error loading results',
cancel: 'Cancel',
delete: 'Delete',
close: 'Close',
save: 'Save',
saveResult: 'Save Result',
section: 'Results',
selectExamError: 'Please select an exam',
continue: 'Continue',
confirmDelete: 'Delete Result',
deleteConfirmText: 'Are you sure you want to delete this result?',
stats: {
totalResults: 'Total Results',
passed: 'Passed',
failed: 'Failed',
avgScore: 'Avg Score',
},
columns: {
seafarer: 'Seafarer',
exam: 'Exam',
totalScore: 'Total Score',
status: 'Status',
date: 'Date',
},
detail: {
title: 'Result Detail',
seafarerProfile: 'Seafarer Profile',
examDetails: 'Exam Details',
fullName: 'Full Name',
gender: 'Gender',
dateOfBirth: 'Date of Birth',
maritalStatus: 'Marital Status',
examTitle: 'Exam Title',
titleAm: 'Title (Amharic)',
type: 'Type',
venue: 'Venue',
date: 'Date',
passMark: 'Pass Mark',
status: 'Status',
remark: 'Remark',
scoreBreakdown: 'Score Breakdown',
question: 'Question',
max: 'Max',
score: 'Score',
remarkShort: 'Remark',
selectExam: 'Choose the exam you want to record a result for.',
exam: 'Exam',
selectExamPlaceholder: 'Select an exam',
},
recordModal: {
title: 'Record Result',
seafarer: 'Seafarer',
seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question',
question: 'Question',
maxPoints: 'Max Points',
score: 'Score',
remark: 'Remark',
remarkOptional: 'Remark (optional)',
remarkPlaceholder: 'Officer remarks',
totalScore: 'Total Score',
passMark: 'Pass Mark',
status: 'Status',
noQuestions: 'No questions assigned to this exam. Assign questions first.',
seafarerRequired: 'Please select a seafarer',
saveSuccess: 'Result recorded',
saveError: 'Failed to save result',
passed: 'PASSED',
failed: 'FAILED',
},
status: {
PASSED: 'PASSED',
FAILED: 'FAILED',
},
action: {
viewEdit: 'View / Edit',
delete: 'Delete',
},
search: {
seafarer: 'Search seafarer...',
filterByExam: 'Filter by exam',
allExams: 'All Exams',
},
},
question: { question: {
title: 'Questions', title: 'Questions',
pool: 'Question Pool', pool: 'Question Pool',

View File

@@ -28,21 +28,21 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
import { useAppDispatch, useAppSelector } from '../store/hooks'; import { useAppDispatch, useAppSelector } from '../store/hooks';
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard }, { to: '/dashboard', label: 'nav.dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUserShield }, { to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
{ to: '/seaman-book-queue', label: 'Seaman Book Queue', icon: IconBook2 }, { to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2 },
{ to: '/coc-queue', label: 'CoC / CoP Queue', icon: IconShieldCheck }, { to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'Endorsement Queue', icon: IconRubberStamp }, { to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp },
{ to: '/seafarer-registry', label: 'Seafarer Registry', icon: IconUsers }, { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
{ to: '/applications', label: 'Applications', icon: IconFileDescription }, { to: '/applications', label: 'nav.applications', icon: IconFileDescription },
{ to: '/payment-config', label: 'Payment Config', icon: IconCreditCard }, { to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard },
{ to: '/analytics', label: 'Analytics', icon: IconChartBar }, { to: '/analytics', label: 'nav.analytics', icon: IconChartBar },
{ to: '/medical-verification', label: 'Medical Verification', icon: IconHeart }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
{ to: '/questions', label: 'Questions', icon: IconQuestionMark }, { to: '/questions', label: 'nav.questions', icon: IconQuestionMark },
{ to: '/exams', label: 'Examinations', icon: IconClipboardList }, { to: '/exams', label: 'nav.exams', icon: IconClipboardList },
{ to: '/exam-results', label: 'Exam Results', icon: IconReport }, { to: '/exam-results', label: 'nav.examResults', icon: IconReport },
{ to: '/configuration', label: 'Configuration', icon: IconSettings }, { to: '/configuration', label: 'nav.configuration', icon: IconSettings },
{ to: '/profile', label: 'Profile', icon: IconUser }, { to: '/profile', label: 'nav.profile', icon: IconUser },
]; ];
const HEADER_HEIGHT = 116; const HEADER_HEIGHT = 116;
@@ -79,7 +79,7 @@ export function BackofficeLayout() {
const go = (item: NavItem) => { const go = (item: NavItem) => {
if (item.soon) { if (item.soon) {
notify.info(`${item.label} — coming soon.`); notify.info(`${t(item.label)} — coming soon.`);
return; return;
} }
if (item.to) { if (item.to) {
@@ -176,7 +176,7 @@ export function BackofficeLayout() {
}} }}
> >
<ItemIcon size={18} stroke={1.6} /> <ItemIcon size={18} stroke={1.6} />
<span>{item.label}</span> <span>{t(item.label)}</span>
</button> </button>
); );
})} })}

View File

@@ -103,7 +103,7 @@ export function AppSidebar({
if (collapsed) { if (collapsed) {
return ( return (
<Tooltip key={item.label} label={item.label} position="right" withArrow> <Tooltip key={item.label} label={t(item.label)} position="right" withArrow>
<UnstyledButton <UnstyledButton
onClick={() => onNavigate(item)} onClick={() => onNavigate(item)}
style={{ style={{
@@ -127,7 +127,7 @@ export function AppSidebar({
<NavLink <NavLink
key={item.label} key={item.label}
active={active} active={active}
label={item.label} label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />} leftSection={<ItemIcon size={19} stroke={1.6} />}
onClick={() => onNavigate(item)} onClick={() => onNavigate(item)}
variant="light" variant="light"