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

@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Stack,
@@ -39,6 +40,8 @@ export function RecordResultModal({
opened: boolean;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const [seafarerSearch, setSeafarerSearch] = useState('');
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
const [scores, setScores] = useState<Record<string, number>>({});
@@ -77,7 +80,7 @@ export function RecordResultModal({
const handleSave = async () => {
if (!selectedSeafarerId) {
notify.error('Please select a seafarer');
notify.error(t('result.recordModal.seafarerRequired'));
return;
}
try {
@@ -96,7 +99,7 @@ export function RecordResultModal({
if (passed && created.id) {
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);
setScores({});
setQuestionRemarks({});
@@ -104,16 +107,16 @@ export function RecordResultModal({
setSeafarerSearch('');
onClose();
} catch {
notify.error('Failed to save result');
notify.error(t('result.recordModal.saveError'));
}
};
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">
<Select
label="Seafarer"
placeholder="Search and select a seafarer"
label={t('result.recordModal.seafarer')}
placeholder={t('result.recordModal.seafarerPlaceholder')}
data={filteredOptions}
value={selectedSeafarerId}
onChange={(v) => {
@@ -129,20 +132,20 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length > 0 && (
<>
<Divider label="Score per Question" labelPosition="center" />
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Question</Table.Th>
<Table.Th>Max Points</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Remark</Table.Th>
<Table.Th>{t('result.recordModal.question')}</Table.Th>
<Table.Th>{t('result.recordModal.maxPoints')}</Table.Th>
<Table.Th>{t('result.recordModal.score')}</Table.Th>
<Table.Th>{t('result.recordModal.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><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>
<NumberInput
@@ -156,7 +159,7 @@ export function RecordResultModal({
</Table.Td>
<Table.Td>
<TextInput
placeholder="Remark (optional)"
placeholder={t('result.recordModal.remarkOptional')}
value={questionRemarks[q.id] ?? ''}
onChange={(e) => handleQuestionRemarkChange(q.id, e.currentTarget.value)}
size="xs"
@@ -170,31 +173,31 @@ export function RecordResultModal({
<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)} />
<InfoRow label={t('result.recordModal.totalScore')} value={String(totalScore)} />
<InfoRow label={t('result.recordModal.passMark')} value={String(exam.cuttingPoint)} />
<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}>
{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></>}
? <><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">{t('result.recordModal.failed')}</Text></>}
</Group>
</div>
</SimpleGrid>
</Paper>
<TextInput
label="Remark (optional)"
placeholder="Officer remarks"
label={t('result.recordModal.remarkOptional')}
placeholder={t('result.recordModal.remarkPlaceholder')}
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
size="sm"
/>
<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}>
Save Result
{t('result.saveResult')}
</Button>
</Group>
</>
@@ -202,7 +205,7 @@ export function RecordResultModal({
{selectedSeafarerId && questions.length === 0 && (
<Alert color="yellow" icon={<IconInfoCircle size={15} />}>
No questions assigned to this exam. Assign questions first.
{t('result.recordModal.noQuestions')}
</Alert>
)}
</Stack>

View File

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