mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 14:15:45 +00:00
feat: add i18n support and localized labels to certification and profession features commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user