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

View File

@@ -24,6 +24,7 @@ import {
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import {
IconArrowLeft,
IconPrinter,
@@ -66,6 +67,8 @@ function InfoRow({ label, value }: { label: string; value: string }) {
}
export function ExamDetailPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const printRef = useRef<HTMLDivElement>(null);
@@ -94,8 +97,8 @@ export function ExamDetailPage() {
if (isError || !exam) {
return (
<Stack gap="md">
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>Back to Exams</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>Exam not found.</Alert>
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
</Stack>
);
}
@@ -180,25 +183,20 @@ export function ExamDetailPage() {
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
const qHtml = (exam.questions ?? []).map((q, i) => {
const full = qMap.get(q.id);
const titleParts = [q.title.en];
if (q.title.am) titleParts.push(q.title.am);
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);
const titleStr = q.title[locale] || q.title.en;
const descStr = full?.description?.[locale] || full?.description?.en || '';
return `
<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="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 === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
</div>`;
}).join('');
printWindow.document.write(`
<html><head><title>${exam.title.en}</title>
<html><head><title>${exam.title[locale] || exam.title.en}</title>
<style>
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; }
@@ -211,12 +209,12 @@ export function ExamDetailPage() {
</style></head><body>
<div class="header">
${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>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>
</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}
<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
@@ -229,7 +227,7 @@ export function ExamDetailPage() {
};
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 (
<Stack gap="md" ref={printRef}>
@@ -240,50 +238,45 @@ export function ExamDetailPage() {
<IconArrowLeft size={18} />
</ActionIcon>
<div>
<Title order={3}>{exam.title.en}</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>
<Title order={3}>{exam.title[locale]}</Title>
</div>
</Group>
<Group gap="sm">
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
Print Exam
{t('exam.print')}
</Button>
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
Record Result
{t('exam.recordResult')}
</Button>
</Group>
</Group>
{/* Status badge */}
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
{exam.status}
{t(`exam.status.${exam.status}`)}
</Badge>
{/* Exam Info */}
<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">
<InfoRow label="Certification" value={certName} />
<InfoRow label="Type" value={TYPE_LABEL[exam.type] ?? exam.type} />
<InfoRow label="Form" value={FORM_LABEL[exam.form] ?? exam.form} />
<InfoRow label="Venue" value={exam.venue} />
<InfoRow label="Date" value={exam.date} />
<InfoRow label="Administration" value={ADMIN_LABEL[exam.administrationMethod] ?? exam.administrationMethod} />
<InfoRow label="Evaluation" value={EVAL_LABEL[exam.evaluationMethod] ?? exam.evaluationMethod} />
<InfoRow label="Selection" value={exam.selectionMethod} />
<InfoRow label="Time Allowed" 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="Total Points" value={String(totalPoints)} />
<InfoRow label="Questions" value={String((exam.questions ?? []).length)} />
<InfoRow label={t('exam.detail.certification')} value={certName} />
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
<InfoRow label={t('exam.detail.date')} value={exam.date} />
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
</SimpleGrid>
{exam.direction?.en && (
{(exam.direction?.en || exam.direction?.am) && (
<>
<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>
@@ -291,28 +284,27 @@ export function ExamDetailPage() {
{/* Questions */}
<Paper withBorder radius="lg" p="lg">
<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}>
Manage Questions
{t('exam.manageQuestions')}
</Button>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
No questions assigned yet. Click "Manage Questions" to assign.
{t('exam.noQuestionsAssigned')}
</Alert>
) : (
<Stack gap="md">
{(exam.questions ?? []).map((q, i) => (
<Paper key={q.id} withBorder p="md" radius="md">
<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}>
<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>
</Group>
</Group>
<Text fz="sm">{q.title.en}</Text>
{q.title.am && <Text fz="xs" c="dimmed" mt={2}>{q.title.am}</Text>}
<Text fz="sm">{q.title[locale]}</Text>
</Paper>
))}
</Stack>
@@ -322,7 +314,7 @@ export function ExamDetailPage() {
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
{/* Question assignment modal */}
<Modal opened={assignOpened} onClose={closeAssign} title={`Manage Questions — ${exam.title.en}`} size="xl" radius="lg">
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')}${exam.title[locale]}`} size="xl" radius="lg">
<Stack gap="md">
{exam.selectionMethod === 'MANUAL' ? (
<>
@@ -333,18 +325,18 @@ export function ExamDetailPage() {
mode="manual"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button>
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
</>
) : (
<>
<Text fz="sm" c="dimmed">
Randomly select questions from the pool of {eligibleQuestions.length} eligible questions. The selection will automatically ensure total points meet the passing mark ({exam.cuttingPoint} pts).
{t('exam.assigner.randomHint', { total: eligibleQuestions.length, pts: exam.cuttingPoint })}
</Text>
<Group gap="sm">
<NumberInput
placeholder="Count"
placeholder={t('exam.assigner.selectCount')}
value={randomCount}
onChange={(v) => setRandomCount(Number(v))}
min={1}
@@ -353,7 +345,7 @@ export function ExamDetailPage() {
style={{ width: 80 }}
/>
<Button size="xs" variant="light" onClick={handleRandomSelect}>
Randomly Select
{t('exam.randomSelect')}
</Button>
</Group>
<QuestionAssigner
@@ -363,8 +355,8 @@ export function ExamDetailPage() {
mode="random"
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeAssign} size="sm">Cancel</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>Save Assignments</Button>
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
</Group>
</>
)}

View File

@@ -23,6 +23,7 @@ import {
Divider,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -56,6 +57,7 @@ function ExamForm({
onSubmit: (values: any, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
@@ -89,56 +91,56 @@ function ExamForm({
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>Basic Info</Tabs.Tab>
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>Settings</Tabs.Tab>
</Tabs.List>
<Tabs defaultValue="basic" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="basic">
<Stack gap="sm">
<Select label="Certification" placeholder="Select certification" 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="Title (Amharic)" placeholder="የፈተና ርዕስ" 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="Direction (Amharic)" placeholder="መመሪያ በአማርኛ" 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="Venue" placeholder="Exam venue" value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
<Tabs.Panel value="basic">
<Stack gap="sm">
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable 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={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<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={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<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={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>
<Group gap="sm" grow>
<NumberInput label="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="Minutes" value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
</Stack>
</Tabs.Panel>
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
<Group gap="sm" grow>
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(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={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="settings">
<Stack gap="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="Form" placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: 'Essay' }, { value: 'CHOICE', label: '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="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="Selection Method" placeholder="Manual or Random" data={[{ value: 'MANUAL', label: 'Manual' }, { value: 'RANDOM', label: '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 />
</SimpleGrid>
{editing && (
<Select label="Status" placeholder="Exam status" data={[
{ value: 'PENDING', label: 'Pending' }, { value: 'ACTIVE', label: 'Active' },
{ value: 'COMPLETED', label: 'Completed' }, { value: 'CANCELLED', label: 'Cancelled' },
{ value: 'POSTPONED', label: 'Postponed' }, { value: 'PUBLISHED', label: 'Published' },
]} value={status} onChange={setStatus} size="sm" />
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Tabs.Panel value="settings">
<Stack gap="sm">
<SimpleGrid cols={2} spacing="sm">
<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={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={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={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={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={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
</SimpleGrid>
{editing && (
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
]} value={status} onChange={setStatus} size="sm" />
)}
</Stack>
</Tabs.Panel>
</Tabs>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update Exam' : 'Create Exam'}</Button>
</Group>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
</Group>
</form>
</Paper>
);
@@ -146,6 +148,8 @@ function ExamForm({
export function ExamPage() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetExamsQuery();
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
@@ -160,8 +164,8 @@ export function ExamPage() {
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
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 getCertName = (id: string) => certifications.find((c) => c.id === id)?.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?.[locale] ?? '-';
const resetForm = () => { setEditing(null); setShowForm(false); };
@@ -185,14 +189,14 @@ export function ExamPage() {
try {
if (isEdit && editing) {
await updateExam({ id: editing.id, ...payload }).unwrap();
notify.success('Exam updated');
notify.success(t('exam.updated'));
} else {
await createExam(payload).unwrap();
notify.success('Exam created');
notify.success(t('exam.created'));
}
resetForm();
} catch {
notify.error('Operation failed');
notify.error(t('exam.error'));
}
};
@@ -200,27 +204,27 @@ export function ExamPage() {
if (!deleteTarget) return;
try {
await deleteExam(deleteTarget.id).unwrap();
notify.success('Exam deleted');
notify.success(t('exam.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error('Failed to delete');
notify.error(t('exam.error'));
}
};
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 (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Examinations</Title>
<Text fz="sm" c="dimmed">Manage exams, assign questions, and track results</Text>
<Title order={2}>{t('exam.title')}</Title>
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
Create Exam
{t('exam.add')}
</Button>
)}
</Group>
@@ -239,14 +243,14 @@ export function ExamPage() {
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>Title</Table.Th>
<Table.Th>Certification</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Venue</Table.Th>
<Table.Th>Questions</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t('exam.columns.title')}</Table.Th>
<Table.Th>{t('exam.columns.certification')}</Table.Th>
<Table.Th>{t('exam.columns.date')}</Table.Th>
<Table.Th>{t('exam.columns.type')}</Table.Th>
<Table.Th>{t('exam.columns.form')}</Table.Th>
<Table.Th>{t('exam.columns.venue')}</Table.Th>
<Table.Th>{t('exam.columns.questions')}</Table.Th>
<Table.Th>{t('exam.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -255,19 +259,19 @@ export function ExamPage() {
<Table.Tr key={exam.id}>
<Table.Td>
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
{exam.title.en}
{exam.title[locale]}
</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><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.form === 'ESSAY' ? 'blue' : 'violet'}>{exam.form}</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'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{exam.status}</Badge>
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
@@ -284,7 +288,7 @@ export function ExamPage() {
{exams.length === 0 && (
<Table.Tr>
<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.Tr>
)}
@@ -293,11 +297,11 @@ export function ExamPage() {
</Paper>
{/* Delete confirmation */}
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Exam" size="sm">
<Text mb="md">Are you sure you want to delete <strong>{deleteTarget?.title?.en}</strong>?</Text>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</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('exam.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
</Group>
</Modal>
</Stack>