feat: add i18n support and localized labels to certification and profession features commit

This commit is contained in:
mengstabketemaw
2026-06-30 15:57:25 +03:00
parent ef69448bbd
commit 9fd59f52ed
19 changed files with 593 additions and 354 deletions

View File

@@ -109,7 +109,7 @@ export function RecordResultModal({
};
return (
<Modal opened={opened} onClose={onClose} title={`Record Result — ${exam.title.en}`} size="90%" radius="lg">
<Modal opened={opened} onClose={onClose} title={`Record Result — ${exam.title.en}`} size="lg" radius="lg">
<Stack gap="md">
<Select
label="Seafarer"
@@ -134,7 +134,6 @@ export function RecordResultModal({
<Table.Thead>
<Table.Tr>
<Table.Th>Question</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Max Points</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Remark</Table.Th>
@@ -144,9 +143,6 @@ export function RecordResultModal({
{questions.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={250} lineClamp={2}>{q.title.en}</Text></Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<NumberInput

View File

@@ -1,4 +1,4 @@
import { useState, type ElementType } from 'react';
import { useState, useCallback, type ElementType } from 'react';
import {
Stack,
Title,
@@ -17,6 +17,7 @@ import {
Button,
ThemeIcon,
Box,
TextInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
@@ -25,23 +26,20 @@ import {
IconTrash,
IconUser,
IconCertificate,
IconScoreboard,
IconCheck,
IconX,
IconCalendar,
IconClock,
IconMapPin,
IconDeviceFloppy,
IconPlus,
IconClipboardList,
IconCircleCheck,
IconCircleX,
IconChartBar,
IconSearch,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation } from '../api/result-api';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
import { useGetExamsQuery } from '../../exam/api/exam-api';
import { RecordResultModal } from '../components/RecordResultModal';
import type { Result } from '../types/result';
import type { Result, ResultBreakdown } from '../types/result';
import type { Exam } from '../../exam/types/exam';
const STATUS_COLOR: Record<string, string> = {
@@ -93,154 +91,6 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
function ResultDetail({ result }: { result: Result }) {
const exam = result.exam;
const profile = result.profile;
const totalScore = Number(result.totalScore);
const cuttingPoint = exam?.cuttingPoint ? Number(exam.cuttingPoint) : 0;
const passed = totalScore >= cuttingPoint;
const formatTime = (t: { days?: number; hours?: number; minutes?: number } | null | undefined) => {
if (!t) return '—';
const parts: string[] = [];
if (t.days) parts.push(`${t.days}d`);
if (t.hours) parts.push(`${t.hours}h`);
if (t.minutes) parts.push(`${t.minutes}m`);
return parts.join(' ') || '—';
};
const formatDate = (d: string | undefined) => d ? new Date(d).toLocaleDateString() : '—';
const formatDob = (d: string | undefined) => d ? new Date(d).toLocaleDateString() : '—';
return (
<Stack gap="md">
{/* Seafarer Profile */}
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconUser size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">Seafarer Profile</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label="Full Name" value={profile ? `${profile.firstName} ${profile.middleName ?? ''} ${profile.lastName}` : result.seafarerId} />
<InfoRow label="Gender" value={profile?.gender ?? '—'} />
<InfoRow label="Date of Birth" value={formatDob(profile?.dob)} />
<InfoRow label="Marital Status" value={profile?.maritalStatus ?? '—'} />
</SimpleGrid>
</Paper>
{/* Exam Details */}
{exam && (
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="violet" radius="xl">
<IconCertificate size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">Exam Details</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label="Exam Title" value={exam.title?.en ?? '—'} />
<InfoRow label="Title (Amharic)" value={exam.title?.am ?? '—'} />
<InfoRow label="Type" value={exam.type ?? '—'} />
<InfoRow label="Form" value={exam.form ?? '—'} />
<InfoRow label="Venue" value={exam.venue ?? '—'} />
<InfoRow label="Date" value={formatDate(exam.date)} />
<InfoRow label="Pass Mark" value={String(cuttingPoint)} />
<InfoRow label="Evaluation" value={exam.evaluationMethod ?? '—'} />
<InfoRow label="Selection" value={exam.selectionMethod ?? '—'} />
<InfoRow label="Time Allowed" value={formatTime(exam.givenTime)} />
<InfoRow label="Status" value={exam.status ?? '—'} />
<InfoRow label="Questions" value={String(exam.questions?.length ?? 0)} />
</SimpleGrid>
{exam.direction?.en && (
<>
<Divider my="sm" />
<InfoRow label="Directions" value={exam.direction.en} />
</>
)}
</Paper>
)}
{/* Score Breakdown */}
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="orange" radius="xl">
<IconScoreboard size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">Score Breakdown</Text>
</Group>
{result.resultBreakdowns.length === 0 ? (
<Text fz="sm" c="dimmed">No breakdown data</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>#</Table.Th>
<Table.Th>Question</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Max</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Remark</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{result.resultBreakdowns.map((b, i) => {
const q = exam?.questions?.find((eq) => eq.id === b.questionId);
return (
<Table.Tr key={b.questionId}>
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
<Table.Td>
<Text fz="xs" lineClamp={2} maw={220}>
{q?.title?.en ?? b.questionId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
{q?.form && (
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>
{q.form}
</Badge>
)}
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{b.score}</Text></Table.Td>
<Table.Td><Text fz="xs">{b.remark || '—'}</Text></Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
)}
<Divider my="sm" />
<Paper withBorder p="sm" radius="md" bg="gray.0">
<SimpleGrid cols={3} spacing="sm">
<InfoRow label="Total Score" value={`${totalScore} / ${cuttingPoint}`} />
<InfoRow label="Pass Mark" value={String(cuttingPoint)} />
<div>
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>Status</Text>
<Group gap={4} mt={2}>
{passed ? (
<><IconCheck size={14} color="var(--mantine-color-teal-6)" /><Text fz="sm" fw={700} c="teal">PASSED</Text></>
) : (
<><IconX size={14} color="var(--mantine-color-red-6)" /><Text fz="sm" fw={700} c="red">FAILED</Text></>
)}
</Group>
</div>
</SimpleGrid>
</Paper>
</Paper>
{result.remark && (
<Paper withBorder p="md" radius="md">
<InfoRow label="Officer Remark" value={result.remark.en || result.remark.am || '—'} />
</Paper>
)}
</Stack>
);
}
export function ResultPage() {
const { data: examRes } = useGetExamsQuery();
const { data, isLoading, isError } = useGetResultsQuery();
@@ -250,11 +100,17 @@ export function ResultPage() {
const results = data?.items ?? [];
const [deleteResult] = useDeleteResultMutation();
const [updateResult] = useUpdateResultMutation();
const [searchQuery, setSearchQuery] = useState('');
const [examFilter, setExamFilter] = useState<string | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [deleteTarget, setDeleteTarget] = useState<Result | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [detailStatus, setDetailStatus] = useState<string>('PASSED');
const [detailRemark, setDetailRemark] = useState<BilingualValue>({ en: '', am: '' });
const [detailBreakdowns, setDetailBreakdowns] = useState<ResultBreakdown[]>([]);
const [detailSaving, setDetailSaving] = useState(false);
const [pickerExamId, setPickerExamId] = useState<string | null>(null);
const [pickerOpened, { open: openPicker, close: closePicker }] = useDisclosure(false);
const [recordExam, setRecordExam] = useState<Exam | null>(null);
@@ -279,7 +135,17 @@ export function ResultPage() {
setPickerExamId(null);
};
const filtered = results.filter((r) => !examFilter || r.examId === examFilter);
const filtered = results.filter((r) => {
if (examFilter && r.examId !== examFilter) return false;
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
if (r.seafarerId.toLowerCase().includes(q)) return true;
if (r.seafarer) {
const name = `${r.seafarer.firstName} ${r.seafarer.middleName ?? ''} ${r.seafarer.lastName}`.toLowerCase();
if (name.includes(q)) return true;
}
return false;
});
const total = results.length;
const passedCount = results.filter((r) => r.status === 'PASSED').length;
@@ -291,9 +157,38 @@ export function ResultPage() {
const getExamTitle = (id: string) => exams.find((e) => e.id === id)?.title?.en ?? '-';
const viewDetail = (result: Result) => {
const viewDetail = useCallback((result: Result) => {
fetchDetail(result.id);
setDetailStatus(result.status);
setDetailRemark({ en: result.remark?.en ?? '', am: result.remark?.am ?? '' });
setDetailBreakdowns(result.resultBreakdowns.map((b) => ({ ...b })));
openDetail();
}, [fetchDetail, openDetail]);
const handleDetailSave = async () => {
if (!detailResult) return;
setDetailSaving(true);
try {
await updateResult({
id: detailResult.id,
status: detailStatus as 'PASSED' | 'FAILED',
remark: detailRemark.en || detailRemark.am ? detailRemark : undefined,
resultBreakdowns: detailBreakdowns,
}).unwrap();
notify.success('Result updated');
closeDetail();
} catch {
notify.error('Failed to update result');
} finally {
setDetailSaving(false);
}
};
const handleDetailClose = () => {
closeDetail();
setDetailBreakdowns([]);
setDetailRemark({ en: '', am: '' });
setDetailStatus('PASSED');
};
const handleDelete = async () => {
@@ -333,15 +228,25 @@ export function ResultPage() {
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Results</Text>
<Select
placeholder="Filter by exam"
data={[{ value: '', label: 'All Exams' }, ...examOptions]}
value={examFilter}
onChange={(v) => setExamFilter(v ?? null)}
size="sm"
style={{ width: 320 }}
clearable
/>
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search seafarer..."
leftSection={<IconSearch size={15} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
size="sm"
style={{ width: 240 }}
/>
<Select
placeholder="Filter by exam"
data={[{ value: '', label: 'All Exams' }, ...examOptions]}
value={examFilter}
onChange={(v) => setExamFilter(v ?? null)}
size="sm"
style={{ width: 280 }}
clearable
/>
</Group>
</Group>
<Table striped highlightOnHover>
@@ -390,7 +295,7 @@ export function ResultPage() {
leftSection={<IconEye size={13} />}
onClick={() => viewDetail(r)}
>
View
View / Edit
</Button>
<Button
size="xs"
@@ -418,7 +323,7 @@ export function ResultPage() {
<Modal
opened={detailOpened}
onClose={closeDetail}
onClose={handleDetailClose}
title="Result Detail"
size="xl"
radius="lg"
@@ -426,7 +331,132 @@ export function ResultPage() {
{isDetailLoading ? (
<Center py="xl"><Loader /></Center>
) : detailResult ? (
<ResultDetail result={detailResult} />
<Stack gap="md">
{/* Seafarer Profile */}
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconUser size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">Seafarer Profile</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 ?? '—'} />
</SimpleGrid>
</Paper>
{/* Exam Details */}
{detailResult.exam && (
<Paper withBorder p="md" radius="md">
<Group gap="sm" mb="sm">
<ThemeIcon size="sm" variant="light" color="violet" radius="xl">
<IconCertificate size={14} />
</ThemeIcon>
<Text fw={600} fz="sm">Exam Details</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)} />
</SimpleGrid>
</Paper>
)}
<Divider />
{/* Editable fields */}
<Select
label="Status"
data={['PASSED', 'FAILED']}
value={detailStatus}
onChange={(v) => setDetailStatus(v ?? 'PASSED')}
size="sm"
/>
<BilingualInput
label="Remark"
placeholder={{ en: 'Officer remark in English', am: 'የኃላፊ አስተያየት በአማርኛ' }}
value={detailRemark}
onChange={setDetailRemark}
size="sm"
/>
{detailBreakdowns.length > 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">Score Breakdown</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.Tr>
</Table.Thead>
<Table.Tbody>
{detailBreakdowns.map((b, i) => {
const examDetail = exams.find((e) => e.id === detailResult.examId);
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
return (
<Table.Tr key={b.questionId}>
<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)}
</Text>
</Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
<Table.Td>
<TextInput
size="xs"
type="number"
style={{ width: 80 }}
value={b.score}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
<Table.Td>
<TextInput
size="xs"
placeholder="Optional"
value={b.remark ?? ''}
onChange={(e) => {
const updated = [...detailBreakdowns];
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
setDetailBreakdowns(updated);
}}
/>
</Table.Td>
</Table.Tr>
);})}
</Table.Tbody>
</Table>
</>
)}
<Group justify="flex-end">
<Button variant="default" onClick={handleDetailClose} size="sm">Close</Button>
<Button
onClick={handleDetailSave}
size="sm"
loading={detailSaving}
leftSection={<IconDeviceFloppy size={15} />}
>
Save
</Button>
</Group>
</Stack>
) : (
<Text c="dimmed" ta="center" py="xl">No data available</Text>
)}