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

@@ -17,6 +17,7 @@ import {
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import {
@@ -38,6 +39,7 @@ function CertificationForm({
onSubmit: (values: { nameEn: string; nameAm: string; descEn: string; descAm: string }, isEdit: boolean) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [nameEn, setNameEn] = useState(editing?.name?.en ?? '');
const [nameAm, setNameAm] = useState(editing?.name?.am ?? '');
const [descEn, setDescEn] = useState(editing?.description?.en ?? '');
@@ -56,13 +58,13 @@ function CertificationForm({
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput label="Name (English)" placeholder="Certificate name in English" value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
<TextInput label="Name (Amharic)" placeholder="የምስክር ወረቀት ስም" value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
<Textarea label="Description (English)" placeholder="English description" value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label="Description (Amharic)" placeholder="የአማርኛ መግለጫ" value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<TextInput label={t('certification.form.nameEn')} placeholder={t('certification.form.nameEnPlaceholder')} value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update' : 'Create'}</Button>
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
</Group>
</Stack>
</form>
@@ -71,6 +73,8 @@ function CertificationForm({
}
export function CertificationPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data, isLoading, isError } = useGetCertificationsQuery();
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
@@ -94,14 +98,14 @@ export function CertificationPage() {
try {
if (isEdit && editing) {
await updateCert({ id: editing.id, name, description }).unwrap();
notify.success('Certification updated');
notify.success(t('certification.updated'));
} else {
await createCert({ name, description }).unwrap();
notify.success('Certification created');
notify.success(t('certification.created'));
}
resetForm();
} catch {
notify.error('Operation failed');
notify.error(t('certification.error'));
}
};
@@ -109,27 +113,27 @@ export function CertificationPage() {
if (!deleteTarget) return;
try {
await deleteCert(deleteTarget.id).unwrap();
notify.success('Certification deleted');
notify.success(t('certification.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error('Failed to delete');
notify.error(t('certification.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading certifications" />;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Certifications</Title>
<Text fz="sm" c="dimmed">Manage certification types (e.g. CoC, CoP)</Text>
<Title order={2}>{t('certification.title')}</Title>
<Text fz="sm" c="dimmed">{t('certification.subtitle')}</Text>
</div>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
Add Certification
{t('certification.add')}
</Button>
)}
</Group>
@@ -147,24 +151,22 @@ export function CertificationPage() {
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>Name (EN)</Table.Th>
<Table.Th>Name (AM)</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t('certification.columns.name')}</Table.Th>
<Table.Th>{t('certification.columns.description')}</Table.Th>
<Table.Th>{t('certification.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{certifications.map((cert) => (
<Table.Tr key={cert.id}>
<Table.Td><Text fz="sm" fw={500}>{cert.name.en}</Text></Table.Td>
<Table.Td>{cert.name.am}</Table.Td>
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
<Table.Td>
<Text fz="sm" lineClamp={2} maw={250}>{cert.description.en || cert.description.am}</Text>
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
{cert.isActive ? 'Active' : 'Inactive'}
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
</Badge>
</Table.Td>
<Table.Td>
@@ -181,8 +183,8 @@ export function CertificationPage() {
))}
{certifications.length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="xl">No certifications found</Text>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
</Table.Td>
</Table.Tr>
)}
@@ -190,11 +192,11 @@ export function CertificationPage() {
</Table>
</Paper>
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Certification" size="sm">
<Text mb="md">Are you sure you want to delete <strong>{deleteTarget?.name?.en}</strong>?</Text>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[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('certification.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
</Group>
</Modal>
</Stack>

View File

@@ -129,7 +129,8 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa
}
function ProfessionTab() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: deptRes } = useGetOrganizationsQuery();
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
@@ -146,7 +147,7 @@ function ProfessionTab() {
const deptOptions = departments.filter((d) => d?.status?.toLowerCase() === 'active').map((d) => ({
value: d.id,
label: d.name?.en ?? d.name ?? '',
label: d.name?.[locale] ?? d.name ?? '',
}));
const resetProfForm = useCallback(() => {
@@ -205,8 +206,8 @@ function ProfessionTab() {
const getDeptName = useCallback((deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? dept.name.en : '-';
}, [departments]);
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
}, [departments, locale]);
if (isLoading) {
return <Center py="xl"><Loader /></Center>;
@@ -245,8 +246,7 @@ function ProfessionTab() {
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.nameEn')}</Table.Th>
<Table.Th>{t('configuration.nameAm')}</Table.Th>
<Table.Th>{t('configuration.name')}</Table.Th>
<Table.Th>{t('configuration.description')}</Table.Th>
<Table.Th>{t('configuration.department')}</Table.Th>
<Table.Th />
@@ -255,10 +255,9 @@ function ProfessionTab() {
<Table.Tbody>
{professions.filter((p) => p.isActive).map((prof) => (
<Table.Tr key={prof.id}>
<Table.Td>{prof.name.en}</Table.Td>
<Table.Td>{prof.name.am}</Table.Td>
<Table.Td>{prof.name[locale]}</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2} maw={200}>{prof.description.en ?? prof.description.am}</Text>
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
</Table.Td>
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
<Table.Td>
@@ -275,7 +274,7 @@ function ProfessionTab() {
))}
{professions.length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Table.Td colSpan={4}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noProfessions')}
</Text>
@@ -287,7 +286,7 @@ function ProfessionTab() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
<Text mb="md">
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.en ?? '' })}
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.[locale] ?? '' })}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
@@ -314,7 +313,7 @@ export function ConfigurationPage() {
{t('location.title')}
</Tabs.Tab>
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
Certifications
{t('certification.title')}
</Tabs.Tab>
</Tabs.List>

View File

@@ -177,14 +177,25 @@ export function ExamDetailPage() {
});
} catch { /* logo not available */ }
const qHtml = (exam.questions ?? []).map((q, i) => `
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);
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 8px 0; font-size: 14px; line-height: 1.5;">${q.title.en}</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>` : ''}
${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('');
</div>`;
}).join('');
printWindow.document.write(`
<html><head><title>${exam.title.en}</title>
@@ -200,13 +211,12 @@ export function ExamDetailPage() {
</style></head><body>
<div class="header">
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
<h1>${exam.title.en}</h1>
<p>${exam.title.am}</p>
<h1>${exam.title.en}${exam.title.am ? ' / ' + exam.title.am : ''}</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 ? `<div class="directions"><strong>Directions:</strong>${exam.direction.en}</div>` : ''}
${exam.direction?.en || exam.direction?.am ? `<div class="directions"><strong>Directions:</strong>${[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')}</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

View File

@@ -35,7 +35,8 @@ export function LocationDetail({
onEdit,
onDelete,
}: LocationDetailProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const typeInfo = locationTypes.find(
(lt) => lt.id === location.locationTypeId,
);
@@ -47,7 +48,7 @@ export function LocationDetail({
return (
<Paper p="lg" radius="md" withBorder>
<Group justify="space-between" mb="xs">
<Title order={4}>{location.names.en}</Title>
<Title order={4}>{location.names[locale]}</Title>
<Group gap="xs">
<Button
variant="light"
@@ -89,7 +90,7 @@ export function LocationDetail({
</Badge>
{typeInfo && (
<Badge size="lg" variant="light" color="teal">
{typeInfo.names.en}
{typeInfo.names[locale]}
</Badge>
)}
</Group>
@@ -99,15 +100,9 @@ export function LocationDetail({
<Stack gap="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameEn')}
{t('location.name')}
</Text>
<Text size="sm">{location.names.en}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameAm')}
</Text>
<Text size="sm">{location.names.am}</Text>
<Text size="sm">{location.names[locale]}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
@@ -120,7 +115,7 @@ export function LocationDetail({
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.type')}
</Text>
<Text size="sm">{typeInfo.names.en} (Level {typeInfo.level})</Text>
<Text size="sm">{typeInfo.names[locale]} ({t('location.level')} {typeInfo.level})</Text>
</div>
)}
</Stack>

View File

@@ -44,7 +44,8 @@ export function LocationForm({
onCancel,
isSubmitting,
}: LocationFormProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const isEditing = !!editingLocation;
@@ -125,7 +126,7 @@ export function LocationForm({
{parentLocation && !isEditing && (
<TextInput
label={t('location.parent')}
value={parentLocation.names.en}
value={parentLocation.names[locale]}
disabled
mb="sm"
size="sm"
@@ -139,7 +140,7 @@ export function LocationForm({
placeholder={t('location.selectType')}
data={allAtLevel.map((lt) => ({
value: lt.id,
label: lt.names.en,
label: lt.names[locale],
}))}
{...form.getInputProps('locationTypeId')}
size="sm"
@@ -153,7 +154,7 @@ export function LocationForm({
</Text>
{type ? (
<Badge size="lg" variant="light" color="blue">
{type.names.en}
{type.names[locale]}
</Badge>
) : (
<Text size="sm" c="red">

View File

@@ -32,7 +32,8 @@ interface LocationTypeFormValues {
}
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
const [createType] = useCreateLocationTypeMutation();
const [updateType] = useUpdateLocationTypeMutation();
@@ -49,9 +50,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
level: 1,
},
validate: {
code: (v) => (!v ? 'Code is required' : null),
namesEn: (v) => (!v ? 'English name is required' : null),
namesAm: (v) => (!v ? 'Amharic name is required' : null),
code: (v) => (!v ? t('location.validation.codeRequired') : null),
namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null),
namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null),
level: (v) => (v < 1 ? 'Level must be at least 1' : null),
},
});
@@ -119,6 +120,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
mt="md"
mb="md"
size="sm"
>
@@ -130,25 +132,25 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
<form onSubmit={handleSubmit}>
<Stack gap="sm" mb="md">
<TextInput
label="Code"
label={t('location.code')}
placeholder="e.g., COUNTRY, REGION, CITY"
{...form.getInputProps('code')}
size="sm"
/>
<TextInput
label="Name (English)"
label={t('location.nameEn')}
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
/>
<TextInput
label="Name (Amharic)"
label={t('location.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
/>
<NumberInput
label="Level"
label={t('location.level')}
placeholder="1"
min={1}
max={10}
@@ -179,10 +181,9 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Level</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Name (EN)</Table.Th>
<Table.Th>Name (AM)</Table.Th>
<Table.Th>{t('location.level')}</Table.Th>
<Table.Th>{t('location.code')}</Table.Th>
<Table.Th>{t('location.name')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
@@ -199,8 +200,7 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
{type.code}
</Badge>
</Table.Td>
<Table.Td>{type.names.en}</Table.Td>
<Table.Td>{type.names.am}</Table.Td>
<Table.Td>{type.names[locale]}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon

View File

@@ -31,7 +31,8 @@ import {
import type { Location } from '../types/location';
export function LocationPage() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
@@ -221,7 +222,7 @@ export function LocationPage() {
>
<Text mb="md">
{t('location.deleteConfirmText', {
name: selectedLocation?.names.en ?? '',
name: selectedLocation?.names[locale] ?? '',
})}
</Text>
<Group justify="flex-end">

View File

@@ -10,7 +10,6 @@ import {
Modal,
Text,
TextInput,
Textarea,
Paper,
Loader,
Center,
@@ -19,6 +18,7 @@ import {
NumberInput,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -44,8 +44,6 @@ function QuestionForm({
certificationId: string;
titleEn: string;
titleAm: string;
descriptionEn: string;
descriptionAm: string;
form: string;
points: number;
days: number;
@@ -54,11 +52,11 @@ function QuestionForm({
}, 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 ?? '');
const [descriptionEn, setDescriptionEn] = useState(editing?.description?.en ?? '');
const [descriptionAm, setDescriptionAm] = useState(editing?.description?.am ?? '');
const [form, setForm] = useState<string | null>(editing?.form ?? null);
const [points, setPoints] = useState<number>(editing?.points ?? 0);
const [days, setDays] = useState(editing?.time?.days ?? 0);
@@ -71,29 +69,29 @@ function QuestionForm({
notify.error('Please fill all required fields');
return;
}
onSubmit({ certificationId, titleEn, titleAm, descriptionEn, descriptionAm, form, points, days, hours, minutes }, !!editing);
onSubmit({
certificationId, titleEn, titleAm, form, points, days, hours, minutes
}, !!editing);
};
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Select label="Certification" placeholder="Select certification" data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label="Title (English)" placeholder="Question 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="Description (English)" placeholder="Question description in English" value={descriptionEn} onChange={(e) => setDescriptionEn(e.currentTarget.value)} size="sm" />
<Textarea label="Description (Amharic)" placeholder="ስለ ዝርዝር መረጃ" value={descriptionAm} onChange={(e) => setDescriptionAm(e.currentTarget.value)} size="sm" />
<Select label="Form" placeholder="Select form" data={[{ value: 'ESSAY', label: 'Essay' }, { value: 'CHOICE', label: 'Choice' }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label="Points" placeholder="Points" value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>Time Allowed</Text>
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
<TextInput label={t('question.form.titleEn')} placeholder={t('question.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
<TextInput label={t('question.form.titleAm')} placeholder={t('question.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
<Select label={t('question.form.form')} placeholder={t('question.form.selectForm')} data={[{ value: 'ESSAY', label: t('question.form.essay') }, { value: 'CHOICE', label: t('question.form.choice') }]} value={form} onChange={setForm} size="sm" required />
<NumberInput label={t('question.form.points')} placeholder={t('question.form.pointsPlaceholder')} value={points} onChange={(v) => setPoints(Number(v))} min={0} size="sm" required />
<Text fz="sm" fw={500}>{t('question.form.timeAllowed')}</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" />
<NumberInput label={t('question.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">Cancel</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? 'Update' : 'Create'}</Button>
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
</Group>
</Stack>
</form>
@@ -102,6 +100,8 @@ function QuestionForm({
}
export function QuestionPage() {
const { t, i18n } = useTranslation();
const locale = i18n.language as 'en' | 'am';
const { data: certRes } = useGetCertificationsQuery();
const { data, isLoading, isError } = useGetQuestionsQuery();
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
@@ -117,33 +117,31 @@ export function QuestionPage() {
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const [certFilter, setCertFilter] = useState<string | null>(null);
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 filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
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 handleSubmit = async (values: {
certificationId: string; titleEn: string; titleAm: string;
descriptionEn: string; descriptionAm: string; form: string;
points: number; days: number; hours: number; minutes: number;
form: string; points: number; days: number; hours: number; minutes: number;
}, isEdit: boolean) => {
const title = { en: values.titleEn, am: values.titleAm };
const description = { en: values.descriptionEn, am: values.descriptionAm };
const time = { days: values.days, hours: values.hours, minutes: values.minutes };
try {
if (isEdit && editing) {
await updateQ({ id: editing.id, certificationId: values.certificationId, title, /* description, */ form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success('Question updated');
await updateQ({ id: editing.id, certificationId: values.certificationId, title, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.updated'));
} else {
await createQ({ certificationId: values.certificationId, title, description, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success('Question created');
await createQ({ certificationId: values.certificationId, title, description: { en: '', am: '' }, form: values.form as QuestionForm, points: values.points, time }).unwrap();
notify.success(t('question.created'));
}
resetForm();
} catch {
notify.error('Operation failed');
notify.error(t('question.error'));
}
};
@@ -151,27 +149,24 @@ export function QuestionPage() {
if (!deleteTarget) return;
try {
await deleteQ(deleteTarget.id).unwrap();
notify.success('Question deleted');
notify.success(t('question.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error('Failed to delete');
notify.error(t('question.error'));
}
};
if (isLoading) return <Center py="xl"><Loader /></Center>;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title="Error loading questions" />;
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-end">
<div>
<Title order={2}>Questions</Title>
<Text fz="sm" c="dimmed">Manage the question pool for examinations</Text>
</div>
<Title order={2}>{t('question.title')}</Title>
{!showForm && (
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
Add Question
{t('question.addQuestion')}
</Button>
)}
</Group>
@@ -188,29 +183,29 @@ export function QuestionPage() {
<Paper withBorder radius="md">
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Text fw={600}>Question Pool</Text>
<Select placeholder="Filter by certification" data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
<Text fw={600}>{t('question.pool')}</Text>
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
</Group>
<Table striped highlightOnHover>
<Table.Thead bg="var(--mantine-color-default-hover)">
<Table.Tr>
<Table.Th>Title (EN)</Table.Th>
<Table.Th>Certification</Table.Th>
<Table.Th>Form</Table.Th>
<Table.Th>Points</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{t('question.columns.title')}</Table.Th>
<Table.Th>{t('question.columns.certification')}</Table.Th>
<Table.Th>{t('question.columns.form')}</Table.Th>
<Table.Th>{t('question.columns.points')}</Table.Th>
<Table.Th>{t('question.columns.status')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((q) => (
<Table.Tr key={q.id}>
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title.en}</Text></Table.Td>
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge></Table.Td>
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={q.isActive ? 'teal' : 'gray'}>{q.isActive ? 'Active' : 'Inactive'}</Badge>
<Badge size="sm" variant="light" color={q.isActive ? 'teal' : 'gray'}>{q.isActive ? t('question.status.active') : t('question.status.inactive')}</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
@@ -227,7 +222,7 @@ export function QuestionPage() {
{filtered.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text c="dimmed" ta="center" py="xl">No questions found</Text>
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
</Table.Td>
</Table.Tr>
)}
@@ -235,11 +230,11 @@ export function QuestionPage() {
</Table>
</Paper>
<Modal opened={deleteOpened} onClose={closeDelete} title="Delete Question" size="sm">
<Text mb="md">Are you sure you want to delete this question?</Text>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
<Text mb="md">{t('question.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('question.cancel')}</Button>
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
</Group>
</Modal>
</Stack>

View File

@@ -30,7 +30,7 @@ export interface ListResponse<T> {
export interface CreateQuestionPayload {
certificationId: string;
title: LocalePair;
description: LocalePair;
description?: LocalePair;
form: QuestionForm;
time?: EstimatedTime;
points: number;

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>
)}

View File

@@ -107,9 +107,11 @@ export const am: Translations = {
addTitle: 'አካባቢ ያክሉ',
editTitle: 'አካባቢ ያስተካክሉ',
code: 'ኮድ',
name: 'ስም',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
type: 'አይነት',
level: 'ደረጃ',
parent: 'ወላጅ',
selectType: 'አይነት ይምረጡ',
cancel: 'ሰርዝ',
@@ -223,6 +225,92 @@ export const am: Translations = {
},
},
certification: {
title: 'የምስክር ወረቀቶች',
subtitle: 'የምስክር ወረቀት አይነቶችን ያስተዳድሩ (ለምሳሌ CoC, CoP)',
add: 'የምስክር ወረቀት ያክሉ',
noItems: 'ምንም የምስክር ወረቀቶች አልተገኙም',
confirmDelete: 'የምስክር ወረቀት ይሰረዝ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
created: 'የምስክር ወረቀት ተፈጥሯል',
updated: 'የምስክር ወረቀት ዘምኗል',
deleted: 'የምስክር ወረቀት ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'የምስክር ወረቀቶችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
delete: 'ሰርዝ',
columns: {
name: 'ስም',
description: 'መግለጫ',
status: 'ሁኔታ',
},
form: {
nameEn: 'ስም (እንግሊዝኛ)',
nameEnPlaceholder: 'የምስክር ወረቀት ስም በእንግሊዝኛ',
nameAm: 'ስም (አማርኛ)',
nameAmPlaceholder: 'የምስክር ወረቀት ስም',
descEn: 'መግለጫ (እንግሊዝኛ)',
descEnPlaceholder: 'የእንግሊዝኛ መግለጫ',
descAm: 'መግለጫ (አማርኛ)',
descAmPlaceholder: 'የአማርኛ መግለጫ',
},
status: {
active: 'ንቁ',
inactive: 'እንቅስቃሴ የሌለ',
},
},
question: {
title: 'ጥያቄዎች',
pool: 'የጥያቄ ማከማቻ',
filterByCertification: 'በምስክር ወረቀት አጣራ',
noQuestions: 'ምንም ጥያቄዎች አልተገኙም',
addQuestion: 'ጥያቄ ያክሉ',
editQuestion: 'ጥያቄ ያስተካክሉ',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት ይህን ጥያቄ መሰረዝ ይፈልጋሉ?',
created: 'ጥያቄ ተፈጥሯል',
updated: 'ጥያቄ ዘምኗል',
deleted: 'ጥያቄ ተሰርዟል',
error: 'ክዋኔው አልተሳካም',
loadError: 'ጥያቄዎችን በመጫን ላይ ስህተት',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
delete: 'ሰርዝ',
columns: {
title: 'ርዕስ',
certification: 'የምስክር ወረቀት',
form: 'ቅጽ',
points: 'ነጥብ',
status: 'ሁኔታ',
},
form: {
certification: 'የምስክር ወረቀት',
selectCertification: 'የምስክር ወረቀት ይምረጡ',
titleEn: 'ርዕስ (እንግሊዝኛ)',
titleEnPlaceholder: 'ጥያቄ በእንግሊዝኛ',
titleAm: 'ርዕስ (አማርኛ)',
titleAmPlaceholder: 'ጥያቄ በአማርኛ',
form: 'ቅጽ',
selectForm: 'ቅጽ ይምረጡ',
essay: 'ኢሴይ',
choice: 'ምርጫ',
points: 'ነጥብ',
pointsPlaceholder: 'ነጥብ',
timeAllowed: 'የተፈቀደ ጊዜ',
days: 'ቀናት',
hours: 'ሰአታት',
minutes: 'ደቂቃዎች',
},
status: {
active: 'ንቁ',
inactive: 'እንቅስቃሴ የሌለ',
},
},
configuration: {
title: 'ውቅረት',
departments: 'ክፍሎች',
@@ -231,8 +319,10 @@ export const am: Translations = {
professionsList: 'ሙያዎች',
addDepartment: 'ክፍል ያክሉ',
addProfession: 'ሙያ ያክሉ',
name: 'ስም',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
description: 'መግለጫ',
descEn: 'መግለጫ (እንግሊዝኛ)',
descAm: 'መግለጫ (አማርኛ)',
department: 'ክፍል',

View File

@@ -105,9 +105,11 @@ export const en = {
addTitle: 'Add Location',
editTitle: 'Edit Location',
code: 'Code',
name: 'Name',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
type: 'Type',
level: 'Level',
parent: 'Parent',
selectType: 'Select type',
cancel: 'Cancel',
@@ -222,6 +224,92 @@ export const en = {
},
},
certification: {
title: 'Certifications',
subtitle: 'Manage certification types (e.g. CoC, CoP)',
add: 'Add Certification',
noItems: 'No certifications found',
confirmDelete: 'Delete Certification',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
created: 'Certification created',
updated: 'Certification updated',
deleted: 'Certification deleted',
error: 'Operation failed',
loadError: 'Error loading certifications',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
delete: 'Delete',
columns: {
name: 'Name',
description: 'Description',
status: 'Status',
},
form: {
nameEn: 'Name (English)',
nameEnPlaceholder: 'Certificate name in English',
nameAm: 'Name (Amharic)',
nameAmPlaceholder: 'የምስክር ወረቀት ስም',
descEn: 'Description (English)',
descEnPlaceholder: 'English description',
descAm: 'Description (Amharic)',
descAmPlaceholder: 'የአማርኛ መግለጫ',
},
status: {
active: 'Active',
inactive: 'Inactive',
},
},
question: {
title: 'Questions',
pool: 'Question Pool',
filterByCertification: 'Filter by certification',
noQuestions: 'No questions found',
addQuestion: 'Add Question',
editQuestion: 'Edit Question',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete this question?',
created: 'Question created',
updated: 'Question updated',
deleted: 'Question deleted',
error: 'Operation failed',
loadError: 'Error loading questions',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
delete: 'Delete',
columns: {
title: 'Title',
certification: 'Certification',
form: 'Form',
points: 'Points',
status: 'Status',
},
form: {
certification: 'Certification',
selectCertification: 'Select certification',
titleEn: 'Title (English)',
titleEnPlaceholder: 'Question in English',
titleAm: 'Title (Amharic)',
titleAmPlaceholder: 'ጥያቄ በአማርኛ',
form: 'Form',
selectForm: 'Select form',
essay: 'Essay',
choice: 'Choice',
points: 'Points',
pointsPlaceholder: 'Points',
timeAllowed: 'Time Allowed',
days: 'Days',
hours: 'Hours',
minutes: 'Minutes',
},
status: {
active: 'Active',
inactive: 'Inactive',
},
},
configuration: {
title: 'Configuration',
departments: 'Departments',
@@ -230,8 +318,10 @@ export const en = {
professionsList: 'Professions',
addDepartment: 'Add Department',
addProfession: 'Add Profession',
name: 'Name',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
description: 'Description',
descEn: 'Description (English)',
descAm: 'Description (Amharic)',
department: 'Department',

View File

@@ -13,7 +13,6 @@ import {
IconFileDescription,
IconHeart,
IconLayoutDashboard,
IconMap,
IconShieldCheck,
IconRubberStamp,
IconSettings,
@@ -39,7 +38,6 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/payment-config', label: 'Payment Config', icon: IconCreditCard },
{ to: '/analytics', label: 'Analytics', icon: IconChartBar },
{ to: '/medical-verification', label: 'Medical Verification', icon: IconHeart },
{ to: '/locations', label: 'Locations', icon: IconMap },
{ to: '/questions', label: 'Questions', icon: IconQuestionMark },
{ to: '/exams', label: 'Examinations', icon: IconClipboardList },
{ to: '/exam-results', label: 'Exam Results', icon: IconReport },

View File

@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
interface LocationPickerProps {
value?: string;
onChange: (locationId: string | null) => void;
onChange?: (locationId: string | null) => void;
onChainChange?: (chain: Location[]) => void;
required?: boolean;
}
export function LocationPicker({ value, onChange, required }: LocationPickerProps) {
export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
const { t } = useTranslation();
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
@@ -54,7 +55,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
current = current.parentId ? locMap.get(current.parentId) : undefined;
}
setSelectedChain(chain);
}, [value, locMap]);
onChainChange?.(chain);
}, [value, locMap, onChainChange]);
const currentLevelChildren = useMemo(() => {
const parentId =
@@ -89,7 +91,8 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
if (!id) {
const newChain = selectedChain.slice(0, -1);
setSelectedChain(newChain);
onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChange?.(newChain.length > 0 ? newChain[newChain.length - 1].id : null);
onChainChange?.(newChain);
return;
}
@@ -100,9 +103,10 @@ export function LocationPicker({ value, onChange, required }: LocationPickerProp
newChain.push(loc);
setSelectedChain(newChain);
onChange(id);
onChange?.(id);
onChainChange?.(newChain);
},
[selectedChain, locMap, onChange, depth],
[selectedChain, locMap, onChange, onChainChange, depth],
);
const buildOptions = (levelIdx: number) => {

View File

@@ -1,6 +1,10 @@
import { useCallback, useMemo } from 'react';
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
import { LocationPicker } from '../../location/components/LocationPicker';
import { useGetLocationTypesQuery } from '../../location/api/location-api';
import type { Location } from '../../location/types/location';
export const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
@@ -25,6 +29,13 @@ export type AddressValues = z.infer<typeof addressSchema>;
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
const LEVEL_TO_FIELD: Record<number, keyof AddressValues> = {
1: 'cityId',
2: 'subcityId',
3: 'woredaId',
4: 'kebeleId',
};
interface AddressFormContentProps {
register: UseFormRegister<AddressValues>;
errors: FieldErrors<AddressValues>;
@@ -40,6 +51,38 @@ export function AddressFormContent({
watch,
trigger,
}: AddressFormContentProps) {
const { data: typesRes } = useGetLocationTypesQuery();
const locationTypes = typesRes?.items ?? [];
const typeLevelMap = useMemo(() => {
const map = new Map<string, number>();
locationTypes.forEach((lt) => map.set(lt.id, lt.level));
return map;
}, [locationTypes]);
const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
const handleChainChange = useCallback(
(chain: Location[]) => {
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
return;
}
setValue('cityId', '');
setValue('subcityId', '');
setValue('woredaId', '');
setValue('kebeleId', '');
chain.forEach((loc) => {
const level = typeLevelMap.get(loc.locationTypeId);
if (level && LEVEL_TO_FIELD[level]) {
setValue(LEVEL_TO_FIELD[level], loc.id);
}
});
},
[setValue, typeLevelMap],
);
return (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
@@ -93,37 +136,11 @@ export function AddressFormContent({
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Region ID"
placeholder="Region UUID (optional)"
{...register('regionId')}
error={errors.regionId?.message}
/>
<TextInput
label="City ID"
placeholder="City UUID (optional)"
{...register('cityId')}
error={errors.cityId?.message}
/>
<TextInput
label="Subcity ID"
placeholder="Subcity UUID (optional)"
{...register('subcityId')}
error={errors.subcityId?.message}
/>
<TextInput
label="Woreda ID"
placeholder="Woreda UUID (optional)"
{...register('woredaId')}
error={errors.woredaId?.message}
/>
<TextInput
label="Kebele ID"
placeholder="Kebele UUID (optional)"
{...register('kebeleId')}
error={errors.kebeleId?.message}
/>
<LocationPicker
value={leafId}
onChainChange={handleChainChange}
/>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
<TextInput
label="Street Address"
placeholder="Street name, house number"

View File

@@ -33,9 +33,8 @@ import {
IconUser,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { notify, BilingualInput } from '@ema-platform/ui';
import type { BilingualValue } from '@ema-platform/ui';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';

View File

@@ -1,3 +1,4 @@
export * from './lib/input/BilingualInput';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';

View File

@@ -12,9 +12,20 @@ export interface BilingualValue {
}
interface BilingualInputProps
extends Omit<TextInputProps, 'value' | 'onChange' | 'rightSection' | 'rightSectionWidth'> {
extends Omit<TextInputProps, 'value' | 'onChange' | 'placeholder' | 'rightSection' | 'rightSectionWidth'> {
value: BilingualValue;
onChange: (value: BilingualValue) => void;
placeholder?: string | BilingualValue;
}
function resolvePlaceholder(placeholder: string | BilingualValue | undefined, lang: 'en' | 'am'): string {
if (!placeholder) {
return lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ';
}
if (typeof placeholder === 'string') {
return placeholder;
}
return placeholder[lang];
}
export function BilingualInput({
@@ -33,7 +44,7 @@ export function BilingualInput({
<TextInput
label={label}
required={required}
placeholder={placeholder ?? (lang === 'en' ? 'Enter in English' : 'በአማርኛ ያስገቡ')}
placeholder={resolvePlaceholder(placeholder, lang)}
value={value[lang]}
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
rightSection={