mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: add i18n support and localized labels to certification and profession features commit
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,16 +228,26 @@ export function ResultPage() {
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>Results</Text>
|
||||
<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: 320 }}
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: 'ክፍል',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/input/BilingualInput';
|
||||
export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
|
||||
@@ -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={
|
||||
Reference in New Issue
Block a user